diff --git a/app/db/seed/index.ts b/app/db/seed/index.ts index c62acc7b8..e617523aa 100644 --- a/app/db/seed/index.ts +++ b/app/db/seed/index.ts @@ -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++) { diff --git a/app/db/tables.ts b/app/db/tables.ts index cc678c002..8b4bf89a8 100644 --- a/app/db/tables.ts +++ b/app/db/tables.ts @@ -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; @@ -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; } 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; @@ -494,13 +519,14 @@ export interface TournamentRound { maps: ColumnType; } +/** A stage is an intermediate phase in a tournament. In essence a bracket. */ export interface TournamentStage { id: GeneratedAlways; 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; noScreen: Generated; + droppedOut: Generated; seed: number | null; tournamentId: number; teamId: number | null; diff --git a/app/features/calendar/CalendarRepository.server.ts b/app/features/calendar/CalendarRepository.server.ts index d88d653df..0c33f078a 100644 --- a/app/features/calendar/CalendarRepository.server.ts +++ b/app/features/calendar/CalendarRepository.server.ts @@ -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 diff --git a/app/features/calendar/calendar-schemas.server.ts b/app/features/calendar/calendar-schemas.server.ts index 915ec41a5..9713fcf2b 100644 --- a/app/features/calendar/calendar-schemas.server.ts +++ b/app/features/calendar/calendar-schemas.server.ts @@ -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 diff --git a/app/features/calendar/calendar-utils.server.ts b/app/features/calendar/calendar-utils.server.ts index db3428396..904f2506e 100644 --- a/app/features/calendar/calendar-utils.server.ts +++ b/app/features/calendar/calendar-utils.server.ts @@ -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; diff --git a/app/features/calendar/calendar-utils.ts b/app/features/calendar/calendar-utils.ts index 2e660a5b7..cd27e07f9 100644 --- a/app/features/calendar/calendar-utils.ts +++ b/app/features/calendar/calendar-utils.ts @@ -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`; } diff --git a/app/features/calendar/routes/calendar.new.tsx b/app/features/calendar/routes/calendar.new.tsx index 637d5065d..04d1ade02 100644 --- a/app/features/calendar/routes/calendar.new.tsx +++ b/app/features/calendar/routes/calendar.new.tsx @@ -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 (
@@ -1142,6 +1150,8 @@ function TournamentFormatSelector() { + +
@@ -1181,7 +1191,53 @@ function TournamentFormatSelector() { ) : null} + {format === "SWISS_TO_SE" ? ( +
+ + +
+ ) : null} + + {format === "SWISS" || format === "SWISS_TO_SE" ? ( +
+ + + {format === "SWISS" ? ( + + 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. + + ) : null} +
+ ) : null} + {format === "RR_TO_SE" || + format === "SWISS_TO_SE" || format === "SE" || (format === "DE" && withUndergroundBracket) ? (
@@ -1196,14 +1252,20 @@ function TournamentFormatSelector() {
) : null} - {format === "RR_TO_SE" ? ( - + {format === "RR_TO_SE" || format === "SWISS_TO_SE" ? ( + ) : null} ); } -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} /> ))}
-
- - {nullFilledArray(teamsPerGroup).map((_, i) => { - const placement = i + 1; - return ( -
- - { - const newPlacements = e.target.checked - ? [...bracket.placements, placement] - : bracket.placements.filter((p) => p !== placement); - onChange({ ...bracket, placements: newPlacements }); - }} - /> -
- ); - })} + {format === "RR_TO_SE" ? ( + + ) : ( + + )} +
+ ); +} + +function FollowUpBracketGroupPlacementCheckboxes({ + teamsPerGroup, + bracket, + onChange, + nth, +}: { + teamsPerGroup: number; + bracket: FollowUpBracket; + onChange: (bracket: FollowUpBracket) => void; + nth: number; +}) { + const id = React.useId(); + + return ( +
+ + {nullFilledArray(teamsPerGroup).map((_, i) => { + const placement = i + 1; + return ( +
+ + { + const newPlacements = e.target.checked + ? [...bracket.placements, placement] + : bracket.placements.filter((p) => p !== placement); + onChange({ ...bracket, placements: newPlacements }); + }} + /> +
+ ); + })} +
+ ); +} + +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 ( +
+ +
+ from + + handleRangeChange([Number(e.target.value), range[1]]) + } + /> + to + + handleRangeChange([range[0], Number(e.target.value)]) + } + />
); diff --git a/app/features/object-damage-calculator/calculator-constants.ts b/app/features/object-damage-calculator/calculator-constants.ts index 55888c59b..771f7e9d0 100644 --- a/app/features/object-damage-calculator/calculator-constants.ts +++ b/app/features/object-damage-calculator/calculator-constants.ts @@ -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"], diff --git a/app/features/tournament-bracket/components/Bracket/RoundRobin.tsx b/app/features/tournament-bracket/components/Bracket/RoundRobin.tsx index 24f5afb29..eaa896a80 100644 --- a/app/features/tournament-bracket/components/Bracket/RoundRobin.tsx +++ b/app/features/tournament-bracket/components/Bracket/RoundRobin.tsx @@ -257,7 +257,9 @@ function PlacementsTable({ > → {dest.name} - ) : null} + ) : ( + + )} ); })} diff --git a/app/features/tournament-bracket/components/Bracket/Swiss.tsx b/app/features/tournament-bracket/components/Bracket/Swiss.tsx new file mode 100644 index 000000000..101e2cfda --- /dev/null +++ b/app/features/tournament-bracket/components/Bracket/Swiss.tsx @@ -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 ( +
+
+ {groups.length > 1 && ( +
+ {groups.map((g) => ( + + ))} +
+ )} +
+ {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 ( +
0 ? "stack md-plus" : "stack"} + > +
+ + {roundThatCanBeStartedId() === round.id ? ( + + + + + Start round + + + ) : null} + {someMatchOngoing(matches) && + tournament.isOrganizer(user) && + roundI > 0 ? ( + + + + ) : null} +
+
+ {matches.length === 0 ? ( +
+ Waiting for the previous round to finish +
+ ) : null} + {matches.map((match) => { + if (!match.opponent1 || !match.opponent2) { + return null; + } + + return ( + + ); + })} +
+ {teamWithBye ? ( +
+ BYE: {teamWithBye.name} +
+ ) : null} +
+ ); + })} +
+ +
+
+ ); +} + +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 ( + + + + + + + + + + + + + + {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 ( + + + + + + + + + {dest ? ( + + ) : ( + + ); + })} + +
Team + W/L + + TB + + W/L (M) + + Buch. + + + Buch. (M) + + Seed +
+ + {s.team.name}{" "} + + {s.team.droppedOut ? ( + + Drop-out + + ) : null} + + + {stats.setWins}/{stats.setLosses} + + + {stats.winsAgainstTied} + + + {stats.mapWins}/{stats.mapLosses} + + + {stats.buchholzSets} + + {stats.buchholzMaps} + {team?.seed} + → {dest.name} + + )} +
+ ); +} diff --git a/app/features/tournament-bracket/components/Bracket/index.tsx b/app/features/tournament-bracket/components/Bracket/index.tsx index a7d1f8710..1a9634bac 100644 --- a/app/features/tournament-bracket/components/Bracket/index.tsx +++ b/app/features/tournament-bracket/components/Bracket/index.tsx @@ -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 ( + + + + ); + } + if (bracket.type === "single_elimination") { return ( diff --git a/app/features/tournament-bracket/components/Bracket/useDeadline.ts b/app/features/tournament-bracket/components/Bracket/useDeadline.ts index d3dd5e9e9..bba60d164 100644 --- a/app/features/tournament-bracket/components/Bracket/useDeadline.ts +++ b/app/features/tournament-bracket/components/Bracket/useDeadline.ts @@ -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, diff --git a/app/features/tournament-bracket/components/BracketMapListDialog.tsx b/app/features/tournament-bracket/components/BracketMapListDialog.tsx index a7ef07974..5be07102a 100644 --- a/app/features/tournament-bracket/components/BracketMapListDialog.tsx +++ b/app/features/tournament-bracket/components/BracketMapListDialog.tsx @@ -62,7 +62,7 @@ export function BracketMapListDialog({ const [hoveredMap, setHoveredMap] = React.useState(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 ( @@ -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 ? ( { const newMapCounts = mapCountsWithGlobalCount(newCount); @@ -207,7 +210,7 @@ export function BracketMapListDialog({ }} /> ) : null} - {bracket.type === "round_robin" ? ( + {globalSelections ? ( ) : null} diff --git a/app/features/tournament-bracket/core/Bracket.ts b/app/features/tournament-bracket/core/Bracket.ts index 01761ae2a..73f96b16a 100644 --- a/app/features/tournament-bracket/core/Bracket.ts +++ b/app/features/tournament-bracket/core/Bracket.ts @@ -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(); + + 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; + } +} diff --git a/app/features/tournament-bracket/core/Swiss.ts b/app/features/tournament-bracket/core/Swiss.ts new file mode 100644 index 000000000..9531f9d3a --- /dev/null +++ b/app/features/tournament-bracket/core/Swiss.ts @@ -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 { + seeding: Array<{ id: number; name: string }>; +} + +export function create(args: CreateArgs): ValueToArray { + 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>()); + + 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[] = []; + 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; +} diff --git a/app/features/tournament-bracket/core/Tournament.ts b/app/features/tournament-bracket/core/Tournament.ts index d15f2c67e..986afe9fb 100644 --- a/app/features/tournament-bracket/core/Tournament.ts +++ b/app/features/tournament-bracket/core/Tournament.ts @@ -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) diff --git a/app/features/tournament-bracket/core/brackets-manager/crud-db.server.ts b/app/features/tournament-bracket/core/brackets-manager/crud-db.server.ts index ade3ef256..e09de7a80 100644 --- a/app/features/tournament-bracket/core/brackets-manager/crud-db.server.ts +++ b/app/features/tournament-bracket/core/brackets-manager/crud-db.server.ts @@ -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, }; } diff --git a/app/features/tournament-bracket/core/mapList.server.ts b/app/features/tournament-bracket/core/mapList.server.ts index 736121f42..0706d0b54 100644 --- a/app/features/tournament-bracket/core/mapList.server.ts +++ b/app/features/tournament-bracket/core/mapList.server.ts @@ -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)); diff --git a/app/features/tournament-bracket/core/summarizer.test.ts b/app/features/tournament-bracket/core/summarizer.test.ts index 24e456b25..7f634b0e3 100644 --- a/app/features/tournament-bracket/core/summarizer.test.ts +++ b/app/features/tournament-bracket/core/summarizer.test.ts @@ -27,6 +27,7 @@ const createTeam = (teamId: number, userIds: number[]): TournamentDataTeam => ({ })), name: "Team " + teamId, prefersNotToHost: 0, + droppedOut: 0, noScreen: 0, team: null, seed: 1, diff --git a/app/features/tournament-bracket/core/tests/mocks.ts b/app/features/tournament-bracket/core/tests/mocks.ts index 885475ec3..eacd6825f 100644 --- a/app/features/tournament-bracket/core/tests/mocks.ts +++ b/app/features/tournament-bracket/core/tests/mocks.ts @@ -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, diff --git a/app/features/tournament-bracket/core/tests/test-utils.ts b/app/features/tournament-bracket/core/tests/test-utils.ts index b286f6629..c0f2f298f 100644 --- a/app/features/tournament-bracket/core/tests/test-utils.ts +++ b/app/features/tournament-bracket/core/tests/test-utils.ts @@ -17,6 +17,7 @@ const tournamentCtxTeam = ( members: [], name: "Team " + teamId, prefersNotToHost: 0, + droppedOut: 0, noScreen: 0, seed: teamId + 1, ...partial, diff --git a/app/features/tournament-bracket/core/toMapList.ts b/app/features/tournament-bracket/core/toMapList.ts index 825bd59cf..21df9e09d 100644 --- a/app/features/tournament-bracket/core/toMapList.ts +++ b/app/features/tournament-bracket/core/toMapList.ts @@ -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; diff --git a/app/features/tournament-bracket/queries/findMatchById.server.ts b/app/features/tournament-bracket/queries/findMatchById.server.ts index e153be7fe..6e9b19b67 100644 --- a/app/features/tournament-bracket/queries/findMatchById.server.ts +++ b/app/features/tournament-bracket/queries/findMatchById.server.ts @@ -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 & { players: string }) & { roundMaps: string | null; diff --git a/app/features/tournament-bracket/routes/to.$id.brackets.tsx b/app/features/tournament-bracket/routes/to.$id.brackets.tsx index b2adc53c4..1863560bd 100644 --- a/app/features/tournament-bracket/routes/to.$id.brackets.tsx +++ b/app/features/tournament-bracket/routes/to.$id.brackets.tsx @@ -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() { ) : null} - {bracket.enoughTeams ? : null} + {bracket.enoughTeams ? ( + + ) : null} {!bracket.enoughTeams ? (
diff --git a/app/features/tournament-bracket/routes/to.$id.matches.$mid.tsx b/app/features/tournament-bracket/routes/to.$id.matches.$mid.tsx index cd7c76098..9e7dfc1e0 100644 --- a/app/features/tournament-bracket/routes/to.$id.matches.$mid.tsx +++ b/app/features/tournament-bracket/routes/to.$id.matches.$mid.tsx @@ -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" diff --git a/app/features/tournament-bracket/tournament-bracket-schemas.server.ts b/app/features/tournament-bracket/tournament-bracket-schemas.server.ts index e70026f53..ead380510 100644 --- a/app/features/tournament-bracket/tournament-bracket-schemas.server.ts +++ b/app/features/tournament-bracket/tournament-bracket-schemas.server.ts @@ -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"), }), diff --git a/app/features/tournament-bracket/tournament-bracket.css b/app/features/tournament-bracket/tournament-bracket.css index 905fb32bb..ccc01e307 100644 --- a/app/features/tournament-bracket/tournament-bracket.css +++ b/app/features/tournament-bracket/tournament-bracket.css @@ -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); } diff --git a/app/features/tournament/TournamentRepository.server.ts b/app/features/tournament/TournamentRepository.server.ts index 096def210..c2d316e6f 100644 --- a/app/features/tournament/TournamentRepository.server.ts +++ b/app/features/tournament/TournamentRepository.server.ts @@ -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, + "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(); +} diff --git a/app/features/tournament/core/sets.server.ts b/app/features/tournament/core/sets.server.ts index d1d228b3e..bb0908135 100644 --- a/app/features/tournament/core/sets.server.ts +++ b/app/features/tournament/core/sets.server.ts @@ -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"; } diff --git a/app/features/tournament/queries/createSwissBracketInTransaction.server.ts b/app/features/tournament/queries/createSwissBracketInTransaction.server.ts new file mode 100644 index 000000000..0ebb71607 --- /dev/null +++ b/app/features/tournament/queries/createSwissBracketInTransaction.server.ts @@ -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, +) { + 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; +} diff --git a/app/features/tournament/routes/to.$id.admin.tsx b/app/features/tournament/routes/to.$id.admin.tsx index 361c202af..111d7f13f 100644 --- a/app/features/tournament/routes/to.$id.admin.tsx +++ b/app/features/tournament/routes/to.$id.admin.tsx @@ -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); } diff --git a/app/features/tournament/tournament-constants.ts b/app/features/tournament/tournament-constants.ts index d9bf099e1..f699e354f 100644 --- a/app/features/tournament/tournament-constants.ts +++ b/app/features/tournament/tournament-constants.ts @@ -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]; diff --git a/app/features/tournament/tournament-schemas.server.ts b/app/features/tournament/tournament-schemas.server.ts index 58b68fe00..6f00f753a 100644 --- a/app/features/tournament/tournament-schemas.server.ts +++ b/app/features/tournament/tournament-schemas.server.ts @@ -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( diff --git a/app/modules/brackets-manager/base/updater.ts b/app/modules/brackets-manager/base/updater.ts index 517bbff05..24bb445a5 100644 --- a/app/modules/brackets-manager/base/updater.ts +++ b/app/modules/brackets-manager/base/updater.ts @@ -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); } diff --git a/app/modules/brackets-manager/helpers.ts b/app/modules/brackets-manager/helpers.ts index cc4ce171f..30f4d20de 100644 --- a/app/modules/brackets-manager/helpers.ts +++ b/app/modules/brackets-manager/helpers.ts @@ -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. * diff --git a/app/modules/brackets-manager/reset.ts b/app/modules/brackets-manager/reset.ts index dc59a3982..372e14702 100644 --- a/app/modules/brackets-manager/reset.ts +++ b/app/modules/brackets-manager/reset.ts @@ -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); } diff --git a/app/modules/brackets-model/input.ts b/app/modules/brackets-model/input.ts index b4fb3f23c..58f3be97c 100644 --- a/app/modules/brackets-model/input.ts +++ b/app/modules/brackets-model/input.ts @@ -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; + }; } diff --git a/app/modules/brackets-model/storage.ts b/app/modules/brackets-model/storage.ts index 521c00ec3..e89495215 100644 --- a/app/modules/brackets-model/storage.ts +++ b/app/modules/brackets-model/storage.ts @@ -100,4 +100,6 @@ export interface Match extends MatchResults { number: number; lastGameFinishedAt?: number | null; + + createdAt?: number | null; } diff --git a/app/modules/brackets-model/unions.ts b/app/modules/brackets-model/unions.ts index 3d2738a7c..50acd7c78 100644 --- a/app/modules/brackets-model/unions.ts +++ b/app/modules/brackets-model/unions.ts @@ -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. diff --git a/app/styles/calendar-new.css b/app/styles/calendar-new.css index 8135660c1..204788f87 100644 --- a/app/styles/calendar-new.css +++ b/app/styles/calendar-new.css @@ -18,3 +18,7 @@ .calendar-new__day-label { margin: 0; } + +.calendar-new__range-input { + width: 4.25rem; +} diff --git a/app/utils/urls.ts b/app/utils/urls.ts index 18ce4ad2d..442840103 100644 --- a/app/utils/urls.ts +++ b/app/utils/urls.ts @@ -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 = ({ diff --git a/e2e/tournament-bracket.spec.ts b/e2e/tournament-bracket.spec.ts index ae42a332b..f99fee34e 100644 --- a/e2e/tournament-bracket.spec.ts +++ b/e2e/tournament-bracket.spec.ts @@ -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 }) => { diff --git a/locales/en/tournament.json b/locales/en/tournament.json index be9c4c12d..e23d8d98b 100644 --- a/locales/en/tournament.json +++ b/locales/en/tournament.json @@ -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", diff --git a/migrations/054-swiss.js b/migrations/054-swiss.js new file mode 100644 index 000000000..dc18cc1c9 --- /dev/null +++ b/migrations/054-swiss.js @@ -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(); + })(); +}