mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-08-02 18:01:29 -05:00
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:
parent
bd0d21f030
commit
f19ec211d8
|
|
@ -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++) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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`;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
|
|
|
|||
|
|
@ -257,7 +257,9 @@ function PlacementsTable({
|
|||
>
|
||||
<span>→ {dest.name}</span>
|
||||
</td>
|
||||
) : null}
|
||||
) : (
|
||||
<td />
|
||||
)}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
|
|
|
|||
432
app/features/tournament-bracket/components/Bracket/Swiss.tsx
Normal file
432
app/features/tournament-bracket/components/Bracket/Swiss.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
571
app/features/tournament-bracket/core/Swiss.ts
Normal file
571
app/features/tournament-bracket/core/Swiss.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ const createTeam = (teamId: number, userIds: number[]): TournamentDataTeam => ({
|
|||
})),
|
||||
name: "Team " + teamId,
|
||||
prefersNotToHost: 0,
|
||||
droppedOut: 0,
|
||||
noScreen: 0,
|
||||
team: null,
|
||||
seed: 1,
|
||||
|
|
|
|||
|
|
@ -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, I’d 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: "There’s 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,
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ const tournamentCtxTeam = (
|
|||
members: [],
|
||||
name: "Team " + teamId,
|
||||
prefersNotToHost: 0,
|
||||
droppedOut: 0,
|
||||
noScreen: 0,
|
||||
seed: teamId + 1,
|
||||
...partial,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -100,4 +100,6 @@ export interface Match extends MatchResults {
|
|||
number: number;
|
||||
|
||||
lastGameFinishedAt?: number | null;
|
||||
|
||||
createdAt?: number | null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -18,3 +18,7 @@
|
|||
.calendar-new__day-label {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.calendar-new__range-input {
|
||||
width: 4.25rem;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 = ({
|
||||
|
|
|
|||
|
|
@ -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 }) => {
|
||||
|
|
|
|||
|
|
@ -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
11
migrations/054-swiss.js
Normal 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();
|
||||
})();
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user