From d7d10fbd788a95428f867df9222e820c8aa59d7f Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Thu, 10 Jul 2025 20:17:58 +0300 Subject: [PATCH] Fix swiss pairing algorithm (#2446) --- .vscode/settings.json | 2 +- .../components/Bracket/PlacementsTable.tsx | 2 + .../tournament-bracket/core/Swiss.test.ts | 66 +- app/features/tournament-bracket/core/Swiss.ts | 499 +++----- .../core/tests/mocks-zones-weekly.ts | 1093 +++++++++++++++++ .../tournament/routes/to.$id.admin.tsx | 2 +- app/features/tournament/routes/to.$id.tsx | 2 +- package-lock.json | 7 + package.json | 1 + scripts/reopen-tournament.ts | 3 + types/edmonds-blossom.d.ts | 1 + 11 files changed, 1317 insertions(+), 361 deletions(-) create mode 100644 app/features/tournament-bracket/core/tests/mocks-zones-weekly.ts create mode 100644 types/edmonds-blossom.d.ts diff --git a/.vscode/settings.json b/.vscode/settings.json index 15ce81c3d..60825dbfa 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -2,6 +2,6 @@ "editor.defaultFormatter": "biomejs.biome", "editor.codeActionsOnSave": { "source.organizeImports.biome": "explicit", - "source.fixAll.biome": "explicit" + "source.fixAll.biome": "never" } } diff --git a/app/features/tournament-bracket/components/Bracket/PlacementsTable.tsx b/app/features/tournament-bracket/components/Bracket/PlacementsTable.tsx index c0f95ff11..327de8158 100644 --- a/app/features/tournament-bracket/components/Bracket/PlacementsTable.tsx +++ b/app/features/tournament-bracket/components/Bracket/PlacementsTable.tsx @@ -89,6 +89,8 @@ export function PlacementsTable({ bracket.tournament.ctx.settings.bracketProgression, ).map((idx) => bracket.tournament.bracketByIdx(idx)!); const canEditDestination = (() => { + if (possibleDestinationBrackets.length === 0) return false; + const allDestinationsPreview = possibleDestinationBrackets.every( (b) => b.preview, ); diff --git a/app/features/tournament-bracket/core/Swiss.test.ts b/app/features/tournament-bracket/core/Swiss.test.ts index 727e5c099..5da8cb3c3 100644 --- a/app/features/tournament-bracket/core/Swiss.test.ts +++ b/app/features/tournament-bracket/core/Swiss.test.ts @@ -1,4 +1,7 @@ import { describe, expect, it } from "vitest"; +import { Tournament } from "~/features/tournament-bracket/core/Tournament"; +import { ZONES_WEEKLY_38 } from "~/features/tournament-bracket/core/tests/mocks-zones-weekly"; +import invariant from "~/utils/invariant"; import * as Swiss from "./Swiss"; describe("Swiss", () => { @@ -98,6 +101,65 @@ describe("Swiss", () => { }); }); - // TODO: - // describe("generateMatchUps()", () => {}); + describe("generateMatchUps()", () => { + describe("Zones Weekly 38", () => { + const tournament = new Tournament({ + ...ZONES_WEEKLY_38(), + simulateBrackets: false, + }); + + const bracket = tournament.bracketByIdx(0)!; + + const matches = Swiss.generateMatchUps({ + bracket, + groupId: 4443, + }); + + it("finds new opponents for each team in the last round", () => { + for (const match of matches) { + if (match.opponentTwo === "null") continue; + + const opponent1 = JSON.parse(match.opponentOne).id as number; + const opponent2 = JSON.parse(match.opponentTwo).id as number; + + const existingMatch = bracket.data.match.find( + (m) => + (m.opponent1?.id === opponent1 && + m.opponent2?.id === opponent2) || + (m.opponent1?.id === opponent2 && m.opponent2?.id === opponent1), + ); + + expect(existingMatch).toBeUndefined(); + } + }); + + it("generates a bye", () => { + const byes = matches.filter((match) => match.opponentTwo === "null"); + expect(byes).toHaveLength(1); + }); + + it("every pair is max one set win from each other", () => { + for (const match of matches) { + if (match.opponentTwo === "null") continue; + + const opponent1 = JSON.parse(match.opponentOne).id as number; + const opponent2 = JSON.parse(match.opponentTwo).id as number; + + const opponent1Stats = bracket.standings.find( + (s) => s.team.id === opponent1, + )?.stats; + const opponent2Stats = bracket.standings.find( + (s) => s.team.id === opponent2, + )?.stats; + + invariant(opponent1Stats, "Opponent 1 not found in standings"); + invariant(opponent2Stats, "Opponent 2 not found in standings"); + + expect( + Math.abs(opponent1Stats.setWins - opponent2Stats.setWins), + ).toBeLessThanOrEqual(1); + } + }); + }); + }); }); diff --git a/app/features/tournament-bracket/core/Swiss.ts b/app/features/tournament-bracket/core/Swiss.ts index ca83e418c..98a0dc53a 100644 --- a/app/features/tournament-bracket/core/Swiss.ts +++ b/app/features/tournament-bracket/core/Swiss.ts @@ -1,12 +1,14 @@ // separate from brackets-manager as this wasn't part of the original brackets-manager library +import blossom from "edmonds-blossom-fixed"; +import * as R from "remeda"; import type { TournamentRepositoryInsertableMatch } from "~/features/tournament/TournamentRepository.server"; import { TOURNAMENT } from "~/features/tournament/tournament-constants"; import type { TournamentManagerDataSet } from "~/modules/brackets-manager/types"; import type { InputStage, Match } from "~/modules/brackets-model"; import { nullFilledArray } from "~/utils/arrays"; import invariant from "~/utils/invariant"; -import type { Bracket, Standing } from "./Bracket"; +import type { Bracket } from "./Bracket"; /** * Creates a Swiss tournament data set (initial matches) based on the provided arguments. Mimics bracket-manager module's interfaces. @@ -145,11 +147,24 @@ function firstRoundMatches({ } } +function everyMatchOver(matches: Match[]) { + for (const match of matches) { + // bye + if (!match.opponent1 || !match.opponent2) continue; + + if (match.opponent1.result !== "win" && match.opponent2.result !== "win") { + return false; + } + } + + return true; +} + /** * Generates the next round of matchups for a Swiss tournament bracket within a specific group. * * Considers only the matches and teams within the specified group. Teams that have dropped out are excluded from the pairing process. - * If the group has an uneven number of teams, the lowest standing team that has not already received a bye will receive one. + * If the group has an uneven number of teams, the lowest standing team that has not already received a bye is preferred to receive one. * Matches are generated such that teams do not replay previous opponents if possible. */ export function generateMatchUps({ @@ -158,7 +173,7 @@ export function generateMatchUps({ }: { bracket: Bracket; groupId: number; -}) { +}): Array { // lets consider only this groups matches // in the case that there are more than one group const groupsMatches = bracket.data.match.filter( @@ -184,67 +199,28 @@ export function generateMatchUps({ (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, + const teamsThatHaveHadByes = groupsMatches + .filter((m) => m.opponent2 === null) + .map((m) => m.opponent1?.id); + + const pairs = pairUp( + standingsWithoutDropouts.map((standing) => ({ + id: standing.team.id, + score: standing.stats?.setWins ?? 0, + receivedBye: teamsThatHaveHadByes.includes(standing.team.id), + avoid: groupsMatches.flatMap((match) => { + if (match.opponent1?.id === standing.team.id) { + return match.opponent2?.id ? [match.opponent2.id] : []; + } + if (match.opponent2?.id === standing.team.id) { + return match.opponent1?.id ? [match.opponent1.id] : []; + } + return []; + }), + })), ); - // 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 + let matchNumber = 1; const newRoundId = bracket.data.round .slice() .sort((a, b) => a.id - b.id) @@ -253,312 +229,123 @@ export function generateMatchUps({ (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]) => ({ + const result: TournamentRepositoryInsertableMatch[] = pairs.map( + ({ opponentOne, opponentTwo }) => ({ groupId, number: matchNumber++, roundId: newRoundId, stageId: groupsMatches[0].stage_id, opponentOne: JSON.stringify({ - id: opponentOneId, - }), - opponentTwo: JSON.stringify({ - id: opponentTwoId, + id: opponentOne, }), + opponentTwo: + typeof opponentTwo === "number" + ? JSON.stringify({ + id: opponentTwo, + }) + : JSON.stringify(null), }), ); - if (bye) { - result.push({ - groupId, - stageId: groupsMatches[0].stage_id, - roundId: newRoundId, - number: matchNumber, - opponentOne: JSON.stringify({ - id: bye.team.id, + return result; +} + +interface SwissPairingTeam { + id: number; + /** How many matches has the team won */ + score: number; + /** List of tournament team ids this team already played */ + avoid: Array; + receivedBye?: boolean; +} + +// adapted from https://github.com/slashinfty/tournament-pairings +function pairUp(players: SwissPairingTeam[]) { + const matches = []; + const playerArray = R.shuffle(players).map((p, i) => ({ ...p, index: i })); + const scoreGroups = [...new Set(playerArray.map((p) => p.score))].sort( + (a, b) => a - b, + ); + const scoreSums = [ + ...new Set( + scoreGroups.flatMap((s, i, a) => { + const sums = []; + for (let j = i; j < a.length; j++) { + sums.push(s + a[j]); + } + return sums; }), - opponentTwo: JSON.stringify(null), + ), + ].sort((a, b) => a - b); + const pairs = []; + for (let i = 0; i < playerArray.length; i++) { + const curr = playerArray[i]; + const next = playerArray.slice(i + 1); + for (let j = 0; j < next.length; j++) { + const opp = next[j]; + if (Object.hasOwn(curr, "avoid") && curr.avoid.includes(opp.id)) { + continue; + } + let wt = + 75 - + 75 / + (scoreGroups.findIndex((s) => s === Math.min(curr.score, opp.score)) + + 2); + wt += + 5 - 5 / (scoreSums.findIndex((s) => s === curr.score + opp.score) + 1); + let scoreGroupDiff = Math.abs( + scoreGroups.findIndex((s) => s === curr.score) - + scoreGroups.findIndex((s) => s === opp.score), + ); + scoreGroupDiff += 0.2; + wt += 23 / (2 * (scoreGroupDiff + 2)); + if ( + (Object.hasOwn(curr, "receivedBye") && curr.receivedBye) || + (Object.hasOwn(opp, "receivedBye") && opp.receivedBye) + ) { + wt += 40; + } + pairs.push([curr.index, opp.index, wt]); + } + } + const blossomPairs = blossom(pairs, true); + const playerCopy = [...playerArray]; + let byeArray = []; + do { + const indexA = playerCopy[0].index; + const indexB = blossomPairs[indexA]; + if (indexB === -1) { + byeArray.push(playerCopy.splice(0, 1)[0]); + continue; + } + playerCopy.splice(0, 1); + playerCopy.splice( + playerCopy.findIndex((p) => p.index === indexB), + 1, + ); + const playerA = playerArray.find((p) => p.index === indexA); + const playerB = playerArray.find((p) => p.index === indexB); + invariant(playerA, "Player A not found"); + invariant(playerB, "Player B not found"); + + matches.push({ + opponentOne: playerA.id, + opponentTwo: playerB.id, + }); + } while ( + playerCopy.length > + blossomPairs.reduce( + (sum: number, idx: number) => (idx === -1 ? sum + 1 : sum), + 0, + ) + ); + byeArray = [...byeArray, ...playerCopy]; + for (let i = 0; i < byeArray.length; i++) { + matches.push({ + opponentOne: byeArray[i].id, + opponentTwo: null, }); } - return 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/75330079 -function makeRounds(n: number) { - const pairings = []; - const max = n - 1; - for (let i = 0; i < max; i++) { - const pairing = [[max, i]]; - for (let k = 1; k < n / 2; k++) { - pairing.push([(i + k) % max, (max + i - k) % max]); - } - pairings.push(pairing); - } - return pairings; -} diff --git a/app/features/tournament-bracket/core/tests/mocks-zones-weekly.ts b/app/features/tournament-bracket/core/tests/mocks-zones-weekly.ts new file mode 100644 index 000000000..8bcc22fb6 --- /dev/null +++ b/app/features/tournament-bracket/core/tests/mocks-zones-weekly.ts @@ -0,0 +1,1093 @@ +import type { TournamentData } from "../Tournament.server"; + +/** Zones Weekly 38 with every round of swiss finished, last round's matches not generated */ +export const ZONES_WEEKLY_38 = (): TournamentData => ({ + data: { + stage: [ + { + id: 1457, + name: "Main Bracket", + number: 1, + settings: { + swiss: { + groupCount: 1, + roundCount: 4, + }, + }, + tournament_id: 891, + type: "swiss", + createdAt: 1734685232, + }, + ], + group: [ + { + id: 4443, + number: 1, + stage_id: 1457, + }, + ], + round: [ + { + id: 13715, + group_id: 4443, + number: 1, + stage_id: 1457, + maps: { + count: 5, + type: "BEST_OF", + pickBan: "COUNTERPICK", + }, + }, + { + id: 13716, + group_id: 4443, + number: 2, + stage_id: 1457, + maps: { + count: 5, + type: "BEST_OF", + pickBan: "COUNTERPICK", + }, + }, + { + id: 13717, + group_id: 4443, + number: 3, + stage_id: 1457, + maps: { + count: 5, + type: "BEST_OF", + pickBan: "COUNTERPICK", + }, + }, + { + id: 13718, + group_id: 4443, + number: 4, + stage_id: 1457, + maps: { + count: 5, + type: "BEST_OF", + pickBan: "COUNTERPICK", + }, + }, + ], + match: [ + { + id: 38584, + group_id: 4443, + number: 1, + opponent1: { + id: 18248, + score: 3, + result: "win", + }, + opponent2: { + id: 18266, + score: 0, + result: "loss", + }, + round_id: 13715, + stage_id: 1457, + status: 4, + lastGameFinishedAt: 1734687487, + createdAt: 1734685232, + }, + { + id: 38585, + group_id: 4443, + number: 2, + opponent1: { + id: 18037, + score: 3, + result: "win", + }, + opponent2: { + id: 18212, + score: 0, + result: "loss", + }, + round_id: 13715, + stage_id: 1457, + status: 4, + lastGameFinishedAt: 1734686471, + createdAt: 1734685232, + }, + { + id: 38586, + group_id: 4443, + number: 3, + opponent1: { + id: 18255, + score: 3, + result: "win", + }, + opponent2: { + id: 18019, + score: 0, + result: "loss", + }, + round_id: 13715, + stage_id: 1457, + status: 4, + lastGameFinishedAt: 1734686359, + createdAt: 1734685232, + }, + { + id: 38587, + group_id: 4443, + number: 4, + opponent1: { + id: 18210, + }, + opponent2: null, + round_id: 13715, + stage_id: 1457, + status: 2, + lastGameFinishedAt: null, + createdAt: 1734685232, + }, + { + id: 38588, + group_id: 4443, + number: 1, + opponent1: { + id: 18248, + score: 3, + result: "win", + }, + opponent2: { + id: 18210, + score: 0, + result: "loss", + }, + round_id: 13716, + stage_id: 1457, + status: 4, + lastGameFinishedAt: 1734688988, + createdAt: 1734687519, + }, + { + id: 38589, + group_id: 4443, + number: 2, + opponent1: { + id: 18037, + score: 3, + result: "win", + }, + opponent2: { + id: 18255, + score: 2, + result: "loss", + }, + round_id: 13716, + stage_id: 1457, + status: 4, + lastGameFinishedAt: 1734689658, + createdAt: 1734687519, + }, + { + id: 38590, + group_id: 4443, + number: 3, + opponent1: { + id: 18266, + score: 3, + result: "win", + }, + opponent2: { + id: 18212, + score: 0, + result: "loss", + }, + round_id: 13716, + stage_id: 1457, + status: 4, + lastGameFinishedAt: 1734688872, + createdAt: 1734687519, + }, + { + id: 38591, + group_id: 4443, + number: 4, + opponent1: { + id: 18019, + }, + opponent2: null, + round_id: 13716, + stage_id: 1457, + status: 2, + lastGameFinishedAt: null, + createdAt: 1734687519, + }, + { + id: 38592, + group_id: 4443, + number: 1, + opponent1: { + id: 18248, + score: 0, + result: "loss", + }, + opponent2: { + id: 18037, + score: 3, + result: "win", + }, + round_id: 13717, + stage_id: 1457, + status: 4, + lastGameFinishedAt: 1734691279, + createdAt: 1734689680, + }, + { + id: 38593, + group_id: 4443, + number: 2, + opponent1: { + id: 18019, + score: 0, + result: "loss", + }, + opponent2: { + id: 18266, + score: 3, + result: "win", + }, + round_id: 13717, + stage_id: 1457, + status: 4, + lastGameFinishedAt: 1734690877, + createdAt: 1734689680, + }, + { + id: 38594, + group_id: 4443, + number: 3, + opponent1: { + id: 18210, + score: 0, + result: "loss", + }, + opponent2: { + id: 18255, + score: 3, + result: "win", + }, + round_id: 13717, + stage_id: 1457, + status: 4, + lastGameFinishedAt: 1734690966, + createdAt: 1734689680, + }, + { + id: 38595, + group_id: 4443, + number: 4, + opponent1: { + id: 18212, + }, + opponent2: null, + round_id: 13717, + stage_id: 1457, + status: 2, + lastGameFinishedAt: null, + createdAt: 1734689680, + }, + ], + }, + ctx: { + id: 891, + eventId: 2698, + discordUrl: "https://discord.gg/A6NW3VCHRh", + tags: "REGION,SZ", + settings: { + bracketProgression: [ + { + type: "swiss", + name: "Main Bracket", + settings: { + roundCount: 4, + }, + requiresCheckIn: false, + }, + ], + thirdPlaceMatch: false, + isRanked: false, + deadlines: "DEFAULT", + isInvitational: false, + enableNoScreenToggle: true, + enableSubs: true, + autonomousSubs: false, + regClosesAt: 1734684300, + requireInGameNames: false, + minMembersPerTeam: 4, + swiss: { + groupCount: 1, + roundCount: 4, + }, + }, + castTwitchAccounts: null, + castedMatchesInfo: null, + mapPickingStyle: "TO", + rules: + "For the complete and up to date rules see #rules and #announcements in the discord.\n\n**Tournament Proceedings**\nContact your opponent through tournament match page. If issues occur, a TO may direct you to a captain’s chat in the discord.\n\n**Map Counterpicks**\nThe loser of each match chooses the next map in the round. A team may not choose a map that has already been played in the set.\n\n**Disconnections**\nEach team can replay once per set when a disconnection occurs on their side if both of the following apply: \n- the disconnection occurs before 2:30 on the match timer.\n- the objective counter of the team without the disconnect is above 40.\nIf a disconnection occurs before 30 seconds into the match then a free replay is given. Please avoid replaying when these conditions aren’t met (i.e. gentlemen’s replay) so to keep the tournament running on time.\n\n**Other Rules**\n- Use of the private battle quit feature for malicious purposes will result in disqualification.\n- Penalties may be issued to teams that are not in the match lobby within 10 minutes of round start.\n\n**Player Restrictions**\nEach team is allowed up to 6 players. Players of the following group are not allowed to participate without specific exemption from Puma\n- Non-OCE players\n- Oceanink banned players\n\n-- Tournament Organisers reserve the right to make last minute changes to the rules —", + parentTournamentId: null, + name: "Zones Weekly 38", + description: + "A short and sweet, weekly zones only tournament for the OCE and SEA region. Format is 4 rounds of Bo5 Swiss with counterpicks.\n\nJoin the discord for more info.", + startTime: 1734685200, + isFinalized: 0, + organization: null, + logoUrl: "tournament-logo-hfX5gzVyrt5QCV8fiQA4n-1716906622859.webp", + logoValidatedAt: 1716949983, + author: { + id: 13370, + username: "Puma", + discordId: "308483655515373570", + discordAvatar: "a5fff2b4706d99364e646cab28c8085b", + customUrl: "puma", + chatNameColor: null, + }, + staff: [ + { + id: 1183, + username: "goobler", + discordId: "395757059922198548", + discordAvatar: "2a569302e9545c6a07f8f8aa337d139d", + customUrl: "penis", + chatNameColor: null, + role: "ORGANIZER", + }, + { + id: 3147, + username: "Cookie", + discordId: "267963609924108288", + discordAvatar: "85090cfe2e0da693355bcec9740c1eaa", + customUrl: "cookie", + chatNameColor: null, + role: "ORGANIZER", + }, + { + id: 5212, + username: "Mars", + discordId: "507102073427460098", + discordAvatar: "bd634c91f7d0475f3671956fa9a2110a", + customUrl: null, + chatNameColor: null, + role: "ORGANIZER", + }, + { + id: 23120, + username: "micah", + discordId: "111682034056835072", + discordAvatar: "f1191c94b1da5396a06b620408017c1f", + customUrl: "weizihao", + chatNameColor: null, + role: "ORGANIZER", + }, + ], + subCounts: [ + { + visibility: "ALL", + count: 4, + }, + ], + bracketProgressionOverrides: [], + teams: [ + { + id: 18248, + name: "Bamboo Pirates", + seed: 1, + prefersNotToHost: 0, + noScreen: 0, + droppedOut: 0, + inviteCode: null, + createdAt: 1734656039, + activeRosterUserIds: [5662, 2899, 6114, 30176], + startingBracketIdx: null, + pickupAvatarUrl: null, + members: [ + { + userId: 5662, + username: "Plus", + discordId: "461787942478675978", + discordAvatar: "b6ab568d3f51973e934892ee8b9f743e", + customUrl: "plussy", + country: "AU", + twitch: "plus218", + isOwner: 1, + createdAt: 1734656039, + inGameName: "Plussy#1291", + }, + { + userId: 2899, + username: "CHIMERA", + discordId: "326295468931940353", + discordAvatar: "a6aed066cec53c079e3585d0c7007be9", + customUrl: "chimera_", + country: "AU", + twitch: "mikamikax_", + isOwner: 0, + createdAt: 1734656044, + inGameName: "CHIMERA#1263", + }, + { + userId: 6114, + username: "Zalph", + discordId: "359945819433992193", + discordAvatar: "8f4fbd64d8949d418f023682e4161afb", + customUrl: "zalph", + country: "SO", + twitch: null, + isOwner: 0, + createdAt: 1734656047, + inGameName: "CountMeOut#1985", + }, + { + userId: 33963, + username: "Tiger", + discordId: "653478041993084938", + discordAvatar: "d67221267888ac818ae4197077d236b7", + customUrl: null, + country: "ST", + twitch: "tigersplat", + isOwner: 0, + createdAt: 1734664082, + inGameName: "BIDOOFGMAX#8251", + }, + { + userId: 30176, + username: "Jorjay", + discordId: "820142030654275644", + discordAvatar: "fb1e6be299a9c37f30c2ab76e55151f6", + customUrl: "2021_spl", + country: "AU", + twitch: null, + isOwner: 0, + createdAt: 1734674285, + inGameName: "Bugha 33#1316", + }, + ], + checkIns: [ + { + bracketIdx: null, + checkedInAt: 1734681705, + isCheckOut: 0, + }, + ], + mapPool: [], + team: { + id: 4259, + customUrl: "bamboo-pirates", + logoUrl: "qv1Zyp4EE72lPghCRheqt-1733399357944.webp", + deletedAt: 1741481029, + }, + avgSeedingSkillOrdinal: 19.23368373822438, + }, + { + id: 18037, + name: "the usual suspects", + seed: 2, + prefersNotToHost: 0, + noScreen: 0, + droppedOut: 0, + inviteCode: null, + createdAt: 1734423187, + activeRosterUserIds: null, + startingBracketIdx: null, + pickupAvatarUrl: "pickup-logo-rZYQMu8ELjiFkeiAVGJUt-1734424882431.webp", + members: [ + { + userId: 17855, + username: "konj", + discordId: "916459853738819624", + discordAvatar: "8d5559ef3e67b5ec4dd1fdcf79f6092c", + customUrl: "kojuke", + country: "NZ", + twitch: null, + isOwner: 1, + createdAt: 1734423187, + inGameName: "☆ SD-J ☆#2947", + }, + { + userId: 21689, + username: "Para", + discordId: "233896056327372802", + discordAvatar: "98f8fc7277ac6664f3db8f5bea0f2785", + customUrl: "voidedparadigm", + country: "AU", + twitch: "voidedparadigm", + isOwner: 0, + createdAt: 1734424893, + inGameName: "parasyka#2169", + }, + { + userId: 3147, + username: "Cookie", + discordId: "267963609924108288", + discordAvatar: "85090cfe2e0da693355bcec9740c1eaa", + customUrl: "cookie", + country: "AU", + twitch: "cookie_spl", + isOwner: 0, + createdAt: 1734426984, + inGameName: "cookie♪#1006", + }, + { + userId: 2072, + username: "suıseı", + discordId: "297233823937069068", + discordAvatar: "19dfa11a14bbf85f52f167d318c4a9df", + customUrl: "qu", + country: "BF", + twitch: null, + isOwner: 0, + createdAt: 1734426986, + inGameName: null, + }, + ], + checkIns: [ + { + bracketIdx: null, + checkedInAt: 1734682339, + isCheckOut: 0, + }, + ], + mapPool: [], + team: null, + avgSeedingSkillOrdinal: 17.028587732217623, + }, + { + id: 18255, + name: "ayam goreng", + seed: 3, + prefersNotToHost: 0, + noScreen: 0, + droppedOut: 0, + inviteCode: null, + createdAt: 1734660846, + activeRosterUserIds: null, + startingBracketIdx: null, + pickupAvatarUrl: null, + members: [ + { + userId: 11484, + username: "Telethia", + discordId: "100913388141441024", + discordAvatar: "5c761e51aea48ffef7d40248f76fa2dc", + customUrl: "telethia", + country: "AU", + twitch: null, + isOwner: 1, + createdAt: 1734660846, + inGameName: "Telethia#6611", + }, + { + userId: 13370, + username: "Puma", + discordId: "308483655515373570", + discordAvatar: "a5fff2b4706d99364e646cab28c8085b", + customUrl: "puma", + country: "AU", + twitch: null, + isOwner: 0, + createdAt: 1734660856, + inGameName: "Puma#2209", + }, + { + userId: 45, + username: "ShockWavee", + discordId: "332893262966685696", + discordAvatar: "ecc26794744c45604c4dc0c12829a178", + customUrl: "shockwavee", + country: "AU", + twitch: "shockwavee03", + isOwner: 0, + createdAt: 1734660882, + inGameName: "ShockWavee#3003", + }, + { + userId: 1843, + username: "hp", + discordId: "600255055836217364", + discordAvatar: "531392eaa7f7706c67a67b0e9c3c8fe1", + customUrl: null, + country: "AU", + twitch: null, + isOwner: 0, + createdAt: 1734663143, + inGameName: null, + }, + ], + checkIns: [ + { + bracketIdx: null, + checkedInAt: 1734683478, + isCheckOut: 0, + }, + ], + mapPool: [], + team: null, + avgSeedingSkillOrdinal: 23.38512079140615, + }, + { + id: 18266, + name: "Fruitea!", + seed: 5, + prefersNotToHost: 0, + noScreen: 1, + droppedOut: 0, + inviteCode: null, + createdAt: 1734683349, + activeRosterUserIds: [37632, 13590, 10757, 33047], + startingBracketIdx: null, + pickupAvatarUrl: null, + members: [ + { + userId: 37632, + username: "mitsi", + discordId: "690098887913898051", + discordAvatar: "92904ce37ba00b98173388d26a668075", + customUrl: "mitsi", + country: "PS", + twitch: null, + isOwner: 1, + createdAt: 1734683349, + inGameName: "mitsi#2589", + }, + { + userId: 13590, + username: "Canary", + discordId: "229149474436415488", + discordAvatar: "b5370ece354a643266d3eb2fb798c250", + customUrl: "canary", + country: "AU", + twitch: "sanityzed", + isOwner: 0, + createdAt: 1734683352, + inGameName: "☆ SD-N ☆#2936", + }, + { + userId: 10757, + username: "Wilds ♪", + discordId: "335966886393151488", + discordAvatar: "a8fcb6c37957349865532149153ef306", + customUrl: "wilds", + country: "AU", + twitch: "whilds", + isOwner: 0, + createdAt: 1734683356, + inGameName: "Wilds ♪#6274", + }, + { + userId: 33047, + username: "layden haw", + discordId: "260167849916497920", + discordAvatar: "aa1f9e79977626dabc7b0f6a1ce33934", + customUrl: null, + country: "AU", + twitch: null, + isOwner: 0, + createdAt: 1734683966, + inGameName: "2F Law#1355", + }, + { + userId: 41024, + username: "Silly", + discordId: "807118113266204682", + discordAvatar: "40ecf7715f974c9c77fab988b7925f02", + customUrl: "silly_b3", + country: "AU", + twitch: "silly_b3", + isOwner: 0, + createdAt: 1734685180, + inGameName: "His Silly#2385", + }, + ], + checkIns: [ + { + bracketIdx: null, + checkedInAt: 1734684005, + isCheckOut: 0, + }, + ], + mapPool: [], + team: { + id: 3327, + customUrl: "fruitea", + logoUrl: "-u2c96lIxBLHuiSaafPgx-1721110885919.webp", + deletedAt: null, + }, + avgSeedingSkillOrdinal: 13.208083181946204, + }, + { + id: 18212, + name: "The Huh Inkqisition", + seed: 6, + prefersNotToHost: 0, + noScreen: 1, + droppedOut: 0, + inviteCode: null, + createdAt: 1734608907, + activeRosterUserIds: [11780, 46006, 43518, 33483], + startingBracketIdx: null, + pickupAvatarUrl: "pickup-logo-FOfFcEbo2OJxIJIJxNJqu-1734608907317.webp", + members: [ + { + userId: 43518, + username: "Veemo.ai", + discordId: "1207579465580937226", + discordAvatar: "26fed7e18436f4acf2a3d69be8c7ef6e", + customUrl: null, + country: "AU", + twitch: "veemo_ai", + isOwner: 1, + createdAt: 1734608907, + inGameName: "H! Veems#3106", + }, + { + userId: 29665, + username: "SounKade", + discordId: "456319133886185493", + discordAvatar: "84fe04756965b996b60bbd259e1e949f", + customUrl: null, + country: "AU", + twitch: "sounkade", + isOwner: 0, + createdAt: 1734608923, + inGameName: "H!PwPwPew#2889", + }, + { + userId: 46006, + username: "Ozzysquid", + discordId: "568696527892250644", + discordAvatar: "6cf81e68116d09422cf1c0b92bffac35", + customUrl: null, + country: "AU", + twitch: "ozzysqid", + isOwner: 0, + createdAt: 1734608925, + inGameName: "H!Ozzysqid#2558", + }, + { + userId: 33483, + username: "Koifu", + discordId: "835478379670405171", + discordAvatar: "8d8549f96de026e6c892fc6ae0533ba2", + customUrl: "koifu", + country: "AU", + twitch: "koifu_spl", + isOwner: 0, + createdAt: 1734608931, + inGameName: "DrkXWolf17#3326", + }, + { + userId: 11780, + username: "𝘚𝘭𝘢𝘯𝘵𝘦𝘥", + discordId: "383285856024264728", + discordAvatar: "1e5da05391f497a80ff8f250e8a48d78", + customUrl: null, + country: "PH", + twitch: null, + isOwner: 0, + createdAt: 1734659216, + inGameName: "Slanted#1646", + }, + { + userId: 37901, + username: "Scuffy", + discordId: "782140779765170177", + discordAvatar: "a_4068013243c9541df51f3505f4865491", + customUrl: null, + country: null, + twitch: "shade_is_special", + isOwner: 0, + createdAt: 1734684084, + inGameName: null, + }, + ], + checkIns: [ + { + bracketIdx: null, + checkedInAt: 1734683891, + isCheckOut: 0, + }, + ], + mapPool: [], + team: null, + avgSeedingSkillOrdinal: -1.2336066064166205, + }, + { + id: 18019, + name: "Monkey Barrel", + seed: 7, + prefersNotToHost: 0, + noScreen: 1, + droppedOut: 0, + inviteCode: null, + createdAt: 1734397954, + activeRosterUserIds: [46467, 46813, 33491, 43662], + startingBracketIdx: null, + pickupAvatarUrl: "pickup-logo-y79k_HOVmjv4KfhTjuSqh-1734398099266.webp", + members: [ + { + userId: 45879, + username: "Albonchap", + discordId: "366392868065247234", + discordAvatar: "2cbf7ae01b702297a2521a171e0e3b78", + customUrl: "albonchap", + country: "AU", + twitch: null, + isOwner: 1, + createdAt: 1734397954, + inGameName: "Albonchap#9998", + }, + { + userId: 43662, + username: "FoolLime", + discordId: "605972217007702036", + discordAvatar: "7bf218fd71fce58c9ba1467ed88e7b4b", + customUrl: null, + country: "AU", + twitch: null, + isOwner: 0, + createdAt: 1734397970, + inGameName: "FoolLime#1864", + }, + { + userId: 33491, + username: "Moth", + discordId: "799230134069755904", + discordAvatar: "cbc9cae07dd9f12cc406b74b926e09d7", + customUrl: null, + country: "AU", + twitch: null, + isOwner: 0, + createdAt: 1734397973, + inGameName: "snowy#2709", + }, + { + userId: 46467, + username: "Veryneggy", + discordId: "293329775920152579", + discordAvatar: "e63a83d27a950d02f665d676133c15b3", + customUrl: "verygoodnegg", + country: "AU", + twitch: null, + isOwner: 0, + createdAt: 1734398287, + inGameName: "Veryneggy#1494", + }, + { + userId: 46813, + username: "Mikil", + discordId: "644853671498088468", + discordAvatar: "ebe43708538e1b6e9c5678f0e341abce", + customUrl: null, + country: "AU", + twitch: null, + isOwner: 0, + createdAt: 1734398628, + inGameName: "Mikil#2961", + }, + ], + checkIns: [ + { + bracketIdx: null, + checkedInAt: 1734682872, + isCheckOut: 0, + }, + ], + mapPool: [], + team: { + id: 4327, + customUrl: "monkey-barrel", + logoUrl: "-5sDkwde5xLxRfbu4Ww1C-1738819882151.webp", + deletedAt: null, + }, + avgSeedingSkillOrdinal: -3.4737419768092437, + }, + { + id: 18210, + name: "Ras+1", + seed: 8, + prefersNotToHost: 0, + noScreen: 0, + droppedOut: 0, + inviteCode: null, + createdAt: 1734598652, + activeRosterUserIds: null, + startingBracketIdx: null, + pickupAvatarUrl: "pickup-logo-IGXFtjFMa_dxQqAe2dqIR-1734598652684.webp", + members: [ + { + userId: 26992, + username: "Dit-toe", + discordId: "584595353370755083", + discordAvatar: "5e922d377f75683321f08004cfc5f6a6", + customUrl: "dit-toad", + country: "AU", + twitch: null, + isOwner: 1, + createdAt: 1734598652, + inGameName: "ЯR Dit-toe#3315", + }, + { + userId: 33611, + username: "Samkat", + discordId: "656420619251875870", + discordAvatar: "c6fc6c98ffea28d513c52e8de52f6157", + customUrl: null, + country: "AU", + twitch: null, + isOwner: 0, + createdAt: 1734598655, + inGameName: "ЯR Samkat #3138", + }, + { + userId: 31148, + username: "smart.png", + discordId: "858608618186342420", + discordAvatar: "abec0f2d5dee5754ef02b71aee25bb73", + customUrl: "cakeatstake", + country: "AU", + twitch: null, + isOwner: 0, + createdAt: 1734598656, + inGameName: "ЯR smart!!#1424", + }, + { + userId: 33578, + username: "Mat", + discordId: "988370975542353960", + discordAvatar: "29784a769af34943558199a7f3e012cb", + customUrl: null, + country: "AU", + twitch: null, + isOwner: 0, + createdAt: 1734612388, + inGameName: "Mat#1561", + }, + ], + checkIns: [ + { + bracketIdx: null, + checkedInAt: 1734684254, + isCheckOut: 0, + }, + ], + mapPool: [], + team: null, + avgSeedingSkillOrdinal: -6.382139240461566, + }, + ], + tieBreakerMapPool: [], + toSetMapPool: [ + { + mode: "SZ", + stageId: 0, + }, + { + mode: "SZ", + stageId: 1, + }, + { + mode: "SZ", + stageId: 2, + }, + { + mode: "SZ", + stageId: 3, + }, + { + mode: "SZ", + stageId: 4, + }, + { + mode: "SZ", + stageId: 5, + }, + { + mode: "SZ", + stageId: 6, + }, + { + mode: "SZ", + stageId: 7, + }, + { + mode: "SZ", + stageId: 8, + }, + { + mode: "SZ", + stageId: 9, + }, + { + mode: "SZ", + stageId: 10, + }, + { + mode: "SZ", + stageId: 11, + }, + { + mode: "SZ", + stageId: 12, + }, + { + mode: "SZ", + stageId: 13, + }, + { + mode: "SZ", + stageId: 14, + }, + { + mode: "SZ", + stageId: 15, + }, + { + mode: "SZ", + stageId: 16, + }, + { + mode: "SZ", + stageId: 17, + }, + { + mode: "SZ", + stageId: 18, + }, + { + mode: "SZ", + stageId: 19, + }, + { + mode: "SZ", + stageId: 20, + }, + { + mode: "SZ", + stageId: 21, + }, + { + mode: "SZ", + stageId: 22, + }, + { + mode: "SZ", + stageId: 23, + }, + ], + participatedUsers: [ + 45, 1843, 2072, 2899, 3147, 5662, 6114, 10757, 11484, 11780, 13370, 13590, + 17855, 21689, 26992, 30176, 31148, 33047, 33491, 33578, 33611, 37632, + 37901, 43518, 43662, 45879, 46006, 46467, 46813, + ], + logoSrc: + "https://sendou.nyc3.cdn.digitaloceanspaces.com/tournament-logo-hfX5gzVyrt5QCV8fiQA4n-1716906622859.webp", + }, +}); diff --git a/app/features/tournament/routes/to.$id.admin.tsx b/app/features/tournament/routes/to.$id.admin.tsx index 62fe183a1..398946edc 100644 --- a/app/features/tournament/routes/to.$id.admin.tsx +++ b/app/features/tournament/routes/to.$id.admin.tsx @@ -45,7 +45,7 @@ export default function TournamentAdminPage() { setEditingProgression(false); }, [tournament]); - if (!tournament.isOrganizer(user) || tournament.everyBracketOver) { + if (!tournament.isOrganizer(user) || tournament.ctx.isFinalized) { return ; } diff --git a/app/features/tournament/routes/to.$id.tsx b/app/features/tournament/routes/to.$id.tsx index 4f6234e92..bb92c39b2 100644 --- a/app/features/tournament/routes/to.$id.tsx +++ b/app/features/tournament/routes/to.$id.tsx @@ -213,7 +213,7 @@ export function TournamentLayout() { !tournament.isLeagueSignup && ( {t("tournament:tabs.seeds")} )} - {tournament.isOrganizer(user) && !tournament.everyBracketOver && ( + {tournament.isOrganizer(user) && !tournament.ctx.isFinalized && ( {t("tournament:tabs.admin")} diff --git a/package-lock.json b/package-lock.json index 0368dbbc1..7dab1f330 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,6 +26,7 @@ "clsx": "^2.1.1", "compressorjs": "^1.2.1", "date-fns": "^4.1.0", + "edmonds-blossom-fixed": "^1.0.1", "gray-matter": "^4.0.3", "i18next": "^23.16.8", "i18next-browser-languagedetector": "^8.0.5", @@ -9612,6 +9613,12 @@ "safe-buffer": "^5.0.1" } }, + "node_modules/edmonds-blossom-fixed": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/edmonds-blossom-fixed/-/edmonds-blossom-fixed-1.0.1.tgz", + "integrity": "sha512-wtpraSt4yJeUpNU8RGC4q2JBxsJbHFxI7/htm/mS4FgxSN90WBwmgE7QZwpcY70KJRqmfLCNxU49UIc+DfzLKQ==", + "license": "MIT" + }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", diff --git a/package.json b/package.json index 925a87bb1..eef3c83b1 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,7 @@ "clsx": "^2.1.1", "compressorjs": "^1.2.1", "date-fns": "^4.1.0", + "edmonds-blossom-fixed": "^1.0.1", "gray-matter": "^4.0.3", "i18next": "^23.16.8", "i18next-browser-languagedetector": "^8.0.5", diff --git a/scripts/reopen-tournament.ts b/scripts/reopen-tournament.ts index 08fde9775..d02441d75 100644 --- a/scripts/reopen-tournament.ts +++ b/scripts/reopen-tournament.ts @@ -14,5 +14,8 @@ sql `delete from "TournamentResult" where "TournamentResult"."tournamentId" = @id`, ) .run({ id }); +sql + .prepare(`update "Tournament" set "isFinalized" = 0 where "id" = @id`) + .run({ id }); logger.info(`Reopened tournament with id ${id}`); diff --git a/types/edmonds-blossom.d.ts b/types/edmonds-blossom.d.ts new file mode 100644 index 000000000..fe1286f70 --- /dev/null +++ b/types/edmonds-blossom.d.ts @@ -0,0 +1 @@ +declare module "edmonds-blossom-fixed";