From ae7b29d98f05ba4b34c844aac141133c2f16a422 Mon Sep 17 00:00:00 2001 From: Karthik99999 Date: Sat, 23 Mar 2024 14:40:28 -0700 Subject: [PATCH 1/4] Add Swiss tournament generator --- server/tournaments/generator-elimination.ts | 9 +- server/tournaments/generator-round-robin.ts | 12 +- server/tournaments/generator-swiss.ts | 327 ++++++++++++++++++++ server/tournaments/index.ts | 30 +- 4 files changed, 362 insertions(+), 16 deletions(-) create mode 100644 server/tournaments/generator-swiss.ts diff --git a/server/tournaments/generator-elimination.ts b/server/tournaments/generator-elimination.ts index fa7983ba310e..c8d6d5e1396c 100644 --- a/server/tournaments/generator-elimination.ts +++ b/server/tournaments/generator-elimination.ts @@ -1,4 +1,5 @@ import {Utils} from '../../lib'; +import type {BracketData, Generator, TournamentPlayer} from './index'; interface ElimTree { root: ElimNode; @@ -6,8 +7,6 @@ interface ElimTree { nextLayerLeafNodes: ElimNode[]; } -import type {TournamentPlayer} from './index'; - /** * There are two types of elim nodes, player nodes * and match nodes. @@ -136,7 +135,7 @@ const nameMap = [ // Feel free to add more ]; -export class Elimination { +export class Elimination implements Generator { readonly name: string; readonly isDrawingSupported: boolean; isBracketFrozen: boolean; @@ -169,13 +168,13 @@ export class Elimination { } } - getPendingBracketData(players: TournamentPlayer[]) { + getPendingBracketData(players: TournamentPlayer[]): BracketData { return { type: 'tree', rootNode: null, }; } - getBracketData() { + getBracketData(): BracketData { return { type: 'tree', rootNode: this.treeRoot.toJSON(), diff --git a/server/tournaments/generator-round-robin.ts b/server/tournaments/generator-round-robin.ts index a908eb7796fc..dfd350bf89ed 100644 --- a/server/tournaments/generator-round-robin.ts +++ b/server/tournaments/generator-round-robin.ts @@ -1,13 +1,13 @@ +import {Utils} from '../../lib/utils'; +import type {BracketData, Generator, TournamentPlayer} from './index'; + interface Match { state: string; score?: number[]; result?: string; } -import {Utils} from '../../lib/utils'; -import type {TournamentPlayer} from './index'; - -export class RoundRobin { +export class RoundRobin implements Generator { readonly name: string; readonly isDrawingSupported: boolean; readonly isDoubles: boolean; @@ -31,7 +31,7 @@ export class RoundRobin { if (isDoubles) this.name = "Double " + this.name; } - getPendingBracketData(players: TournamentPlayer[]) { + getPendingBracketData(players: TournamentPlayer[]): BracketData { return { type: 'table', tableHeaders: { @@ -51,7 +51,7 @@ export class RoundRobin { scores: players.map(player => 0), }; } - getBracketData() { + getBracketData(): BracketData { const players = this.players; return { type: 'table', diff --git a/server/tournaments/generator-swiss.ts b/server/tournaments/generator-swiss.ts new file mode 100644 index 000000000000..601cadef3f0d --- /dev/null +++ b/server/tournaments/generator-swiss.ts @@ -0,0 +1,327 @@ +import {Utils} from '../../lib'; +import {BracketData, Generator, TournamentPlayer} from './index'; + +interface Match { + /** Second player is null for bye */ + players: [SwissPlayer, SwissPlayer | null]; + state: 'available' | 'finished' | 'unavailable'; + result?: string; + score?: number[]; +} + +class SwissPlayer { + user: TournamentPlayer; + matches: [SwissPlayer, string][]; + bye: number; + /** Opponents' Win % */ + owp: number; + /** Opponents' Opponents' Win % */ + oowp: number; + lockResistance: boolean; + constructor(user: TournamentPlayer) { + this.user = user; + this.matches = []; + this.bye = 0; + this.owp = 0.25; + this.oowp = 0.25; + this.lockResistance = false; + } + + getWL() { + const draws = this.user.games - this.user.wins - this.user.losses; + return `${this.user.wins}-${this.user.losses}${draws > 0 ? `-${draws}` : ''}${this.user.isDisqualified ? ' (DQ)' : ''}`; + } + + recalculateOWP() { + if (this.lockResistance || !this.matches.length) return; + let totalWP = 0; + for (const [opp] of this.matches) { + let wp = Math.max(opp.matches.filter(([_, result]) => result === 'win').length / opp.matches.length, 0.25); + if (opp.user.isDisqualified) wp = Math.min(wp, 0.75); + totalWP += wp; + } + this.owp = totalWP / this.matches.length; + } + recalculateOOWP() { + if (this.lockResistance || !this.matches.length) return; + let totalOWP = 0; + for (const [opp] of this.matches) { + totalOWP += opp.owp; + } + this.oowp = totalOWP / this.matches.length; + } + + checkTie(otherPlayer: SwissPlayer) { + if ((this.user.score - this.bye) !== (otherPlayer.user.score - otherPlayer.bye)) return false; + if (this.owp !== otherPlayer.owp) return false; + if (this.oowp !== otherPlayer.oowp) return false; + return true; + } +} + +export class Swiss implements Generator { + readonly name = 'Swiss'; + readonly isDrawingSupported = true; + isBracketFrozen: boolean; + players: SwissPlayer[]; + matches: Match[]; + currentRound: number; + rounds: number; + ended: boolean; + constructor() { + this.isBracketFrozen = false; + this.players = []; + this.matches = []; + this.currentRound = 0; + this.rounds = 0; + this.ended = false; + } + + getPendingBracketData(players: TournamentPlayer[]): BracketData { + return { + type: 'tree', + }; + } + getBracketData(): BracketData { + if (this.ended) { + return { + type: 'table', + tableHeaders: { + cols: ['Player', 'Record', 'Op Win %', 'Op Op Win %'], + rows: Array.from(this.players, (_, i) => i + 1), + }, + tableContents: this.players.map(player => [ + {text: player.user.name}, + {text: player.getWL()}, + {text: (player.owp * 100).toFixed(2) + '%'}, + {text: (player.oowp * 100).toFixed(2) + '%'}, + ]), + }; + } else { + return { + type: 'table', + tableHeaders: { + cols: ['Player 1', 'Record', 'Player 2', 'Record', 'Status'], + rows: Array.from(this.matches, (_, i) => `Match ${i + 1}`), + }, + tableContents: this.matches.map(match => { + const status: AnyObject = { + state: match.state, + }; + if (match.state === 'finished') { + status.result = match.result; + status.score = match.score; + } + const pendingChallenge = match.players[0].user.pendingChallenge; + const inProgressMatch = match.players[0].user.inProgressMatch; + if (pendingChallenge) { + status.state = 'challenging'; + } else if (inProgressMatch) { + status.state = 'inprogress'; + status.room = inProgressMatch.room.roomid; + } + return [ + {text: match.players[0].user.name}, + {text: match.players[0].getWL()}, + {text: match.players[1]?.user.name || 'BYE'}, + {text: match.players[1]?.getWL() || ''}, + status, + ]; + }), + }; + } + } + freezeBracket(players: TournamentPlayer[]) { + this.players = Utils.shuffle(players.map(player => new SwissPlayer(player))); + this.isBracketFrozen = true; + this.rounds = Math.ceil(Math.log2(this.players.length)); + this.advanceRound(); + } + disqualifyUser(user: TournamentPlayer) { + if (!this.isBracketFrozen) return 'BracketNotFrozen'; + + const player = this.players.find(p => p.user === user)!; + const match = this.matches.find(m => m.players[0] === player || m.players[1] === player); + if (match && match.state === 'available') { + let opponent; + if (match.players[0] === player) { + opponent = match.players[1]!; + match.result = 'loss'; + match.score = [0, 1]; + } else { + opponent = match.players[0]; + match.result = 'win'; + match.score = [1, 0]; + } + player.user.losses++; + player.matches.push([opponent, 'loss']); + opponent.user.wins++; + opponent.user.score++; + opponent.matches.push([player, 'win']); + match.state = 'finished'; + } + if (this.matches.every(m => m.state === 'finished' || m.state === 'unavailable')) { + this.advanceRound(); + } + + user.game.setPlayerUser(user, null); + } + sortPlayers(finalResults = false) { + // OOWP depends on OWP of all players, so we need to calculate all OWPs first + for (const player of this.players) { + player.recalculateOWP(); + } + for (const player of this.players) { + player.recalculateOOWP(); + } + + if (finalResults) { + this.players.sort((a, b) => b.user.score - a.user.score || b.owp - a.owp || b.oowp - a.oowp); + + const groups: SwissPlayer[][] = []; + let currentGroup: SwissPlayer[] = []; + for (const player of this.players) { + if (!currentGroup.length || player.checkTie(currentGroup[0])) { + currentGroup.push(player); + } else { + groups.push(currentGroup); + currentGroup = [player]; + } + } + groups.push(currentGroup); + + // Final tiebreaker. Head to Head if applicable, or shuffle + this.players = groups.flatMap(group => { + if (group.length === 2) { + const match = group[0].matches.find(([opponent]) => opponent === group[1]); + if (match) { + if (match[1] === 'win') return group; + if (match[1] === 'loss') return [group[1], group[0]]; + } + } + return Utils.shuffle(group); + }); + } else { + this.players.sort((a, b) => b.user.score - a.user.score); + + const groups: SwissPlayer[][] = []; + let currentGroup: SwissPlayer[] = []; + for (const player of this.players) { + if (!currentGroup.length || currentGroup[0].user.score - player.user.score <= 0.5) { + currentGroup.push(player); + } else { + groups.push(currentGroup); + currentGroup = [player]; + } + } + groups.push(currentGroup); + + this.players = groups.flatMap(Utils.shuffle); + } + } + advanceRound() { + if (this.currentRound === this.rounds) { + this.ended = true; + return; + } + this.currentRound++; + + this.sortPlayers(); + for (const player of this.players) { + if (player.user.isDisqualified) player.lockResistance = true; + } + + const players = this.players.filter(p => !p.user.isDisqualified); + const pairedPlayers = new Set(); + this.matches = []; + + // If odd number of players, give a bye to the lowest ranked player who has not had a bye + let byePlayer: SwissPlayer | undefined; + if (players.length % 2 === 1) { + for (let i = players.length - 1; i >= 0; i--) { + if (players[i].bye === 0) { + byePlayer = players[i]; + pairedPlayers.add(byePlayer); + break; + } + } + } + + for (let i = 0; i < players.length; i++) { + const p1 = players[i]; + if (pairedPlayers.has(p1)) continue; + let p2: SwissPlayer | undefined; + for (let j = i + 1; j < players.length; j++) { + if (pairedPlayers.has(players[j])) continue; + if (p1.matches.some(([opponent]) => opponent === players[j])) continue; + p2 = players[j]; + break; + } + // If we already played everyone, just allow the repair + p2 ||= players.slice(i + 1).find(p => !pairedPlayers.has(p)); + if (!p2) throw new Error(`Failed to pair player ${p1.user.name}`); + this.matches.push({players: [p1, p2], state: 'available'}); + pairedPlayers.add(p1); + pairedPlayers.add(p2); + } + + // Doing this here so the match gets added at the end + if (byePlayer) { + this.matches.push({players: [byePlayer, null], state: 'unavailable'}); + byePlayer.bye = 1; + byePlayer.user.wins++; + byePlayer.user.score++; + } + } + getAvailableMatches() { + if (!this.isBracketFrozen) return 'BracketNotFrozen'; + + const matches: [TournamentPlayer, TournamentPlayer][] = []; + for (const match of this.matches) { + if (match.state !== 'available') continue; + if (match.players.some(p => p!.user.isBusy)) continue; + matches.push([match.players[0].user, match.players[1]!.user]); + } + return matches; + } + setMatchResult(match: [TournamentPlayer, TournamentPlayer], result: string, score: number[]) { + if (!this.isBracketFrozen) return 'BracketNotFrozen'; + if (!['win', 'loss', 'draw'].includes(result)) return 'InvalidMatchResult'; + const p1 = this.players.find(p => p.user === match[0]); + const p2 = this.players.find(p => p.user === match[1]); + if (!p1 || !p2) return 'UserNotAdded'; + const swissMatch = this.matches.find(m => m.players[0] === p1 && m.players[1] === p2); + if (!swissMatch || swissMatch.state !== 'available') return 'InvalidMatch'; + + switch (result) { + case 'win': + p1.matches.push([p2, 'win']); + p2.matches.push([p1, 'loss']); + break; + case 'loss': + p1.matches.push([p2, 'loss']); + p2.matches.push([p1, 'win']); + break; + default: + p1.matches.push([p2, 'draw']); + p2.matches.push([p1, 'draw']); + break; + } + + swissMatch.state = 'finished'; + swissMatch.result = result; + swissMatch.score = score.slice(); + if (this.matches.every(m => m.state === 'finished' || m.state === 'unavailable')) { + this.advanceRound(); + } + } + isTournamentEnded() { + return this.ended; + } + getResults() { + if (!this.isTournamentEnded()) return 'TournamentNotEnded'; + + this.sortPlayers(true); + return this.players.map(p => [p.user]); + } +} diff --git a/server/tournaments/index.ts b/server/tournaments/index.ts index 120e221aa949..d91201e98f26 100644 --- a/server/tournaments/index.ts +++ b/server/tournaments/index.ts @@ -1,6 +1,7 @@ import {Elimination} from './generator-elimination'; import {RoundRobin} from './generator-round-robin'; +import {Swiss} from './generator-swiss'; import {Utils} from '../../lib'; import {PRNG} from '../../sim/prng'; import type {BestOfGame} from '../room-battle-bestof'; @@ -20,7 +21,25 @@ export interface TournamentRoomSettings { blockRecents?: boolean; } -type Generator = RoundRobin | Elimination; +export interface BracketData { + type: 'tree' | 'table'; + [k: string]: any; +} + +export interface Generator { + readonly name: string; + readonly isDrawingSupported: boolean; + isBracketFrozen: boolean; + + getPendingBracketData(players: TournamentPlayer[]): BracketData; + getBracketData(): BracketData; + freezeBracket(players: TournamentPlayer[]): void; + disqualifyUser(user: TournamentPlayer): string | void; + getAvailableMatches(): [TournamentPlayer, TournamentPlayer][] | string; + setMatchResult(match: [TournamentPlayer, TournamentPlayer], result: string, score: number[]): string | void; + isTournamentEnded(): boolean; + getResults(): (TournamentPlayer | null)[][] | string; +} const BRACKET_MINIMUM_UPDATE_INTERVAL = 2 * 1000; const AUTO_DISQUALIFY_WARNING_TIMEOUT = 30 * 1000; @@ -39,6 +58,7 @@ const TournamentGenerators = { __proto__: null, roundrobin: RoundRobin, elimination: Elimination, + swiss: Swiss, }; function usersToNames(users: TournamentPlayer[]) { @@ -299,7 +319,7 @@ export class Tournament extends Rooms.RoomGame { const possiblePlayer = this.playerTable[targetUser.id]; let isJoined = false; if (possiblePlayer) { - if (this.generator.name.includes("Elimination")) { + if (this.generator.name.includes("Elimination") || this.generator.name === 'Swiss') { isJoined = !possiblePlayer.isEliminated && !possiblePlayer.isDisqualified; } else if (this.generator.name.includes("Round Robin")) { if (possiblePlayer.isDisqualified) { @@ -573,7 +593,7 @@ export class Tournament extends Rooms.RoomGame { } getBracketData() { - let data: any; + let data: BracketData; if (!this.isTournamentStarted) { data = this.generator.getPendingBracketData(this.players); } else { @@ -611,7 +631,7 @@ export class Tournament extends Rooms.RoomGame { } } } - } else if (data.type === 'table') { + } else if (data.type === 'table' && this.generator.name.includes("Round Robin")) { if (this.isTournamentStarted) { for (const [r, row] of data.tableContents.entries()) { const pendingChallenge = data.tableHeaders.rows[r].pendingChallenge; @@ -1205,7 +1225,7 @@ function getGenerator(generator: string | undefined) { case 'elim': generator = 'elimination'; break; case 'rr': generator = 'roundrobin'; break; } - return TournamentGenerators[generator as 'elimination' | 'roundrobin']; + return TournamentGenerators[generator as 'elimination' | 'roundrobin' | 'swiss']; } function createTournamentGenerator( From e7b8a4343f0750d11efb75b90e3282dd3013e015 Mon Sep 17 00:00:00 2001 From: Karthik99999 Date: Sat, 23 Mar 2024 18:43:47 -0700 Subject: [PATCH 2/4] Optimize per round sorting/shuffling --- server/tournaments/generator-swiss.ts | 16 ++++------------ 1 file changed, 4 insertions(+), 12 deletions(-) diff --git a/server/tournaments/generator-swiss.ts b/server/tournaments/generator-swiss.ts index 601cadef3f0d..41377d95c232 100644 --- a/server/tournaments/generator-swiss.ts +++ b/server/tournaments/generator-swiss.ts @@ -202,21 +202,13 @@ export class Swiss implements Generator { return Utils.shuffle(group); }); } else { - this.players.sort((a, b) => b.user.score - a.user.score); - const groups: SwissPlayer[][] = []; - let currentGroup: SwissPlayer[] = []; for (const player of this.players) { - if (!currentGroup.length || currentGroup[0].user.score - player.user.score <= 0.5) { - currentGroup.push(player); - } else { - groups.push(currentGroup); - currentGroup = [player]; - } + const groupIndex = Math.ceil(player.user.score); + if (!groups[groupIndex]) groups[groupIndex] = []; + groups[groupIndex].push(player); } - groups.push(currentGroup); - - this.players = groups.flatMap(Utils.shuffle); + this.players = groups.filter(Boolean).reverse().flatMap(Utils.shuffle); } } advanceRound() { From a1126208f9b885a5a0e82b504e5357d0365c9a6b Mon Sep 17 00:00:00 2001 From: Karthik99999 Date: Sun, 31 Mar 2024 23:43:42 -0700 Subject: [PATCH 3/4] nitpicky changes --- server/tournaments/generator-swiss.ts | 64 +++++++++++++-------------- 1 file changed, 32 insertions(+), 32 deletions(-) diff --git a/server/tournaments/generator-swiss.ts b/server/tournaments/generator-swiss.ts index 41377d95c232..e48ee74330e9 100644 --- a/server/tournaments/generator-swiss.ts +++ b/server/tournaments/generator-swiss.ts @@ -2,8 +2,9 @@ import {Utils} from '../../lib'; import {BracketData, Generator, TournamentPlayer} from './index'; interface Match { - /** Second player is null for bye */ - players: [SwissPlayer, SwissPlayer | null]; + p1: SwissPlayer, + /** null for bye */ + p2: SwissPlayer | null, state: 'available' | 'finished' | 'unavailable'; result?: string; score?: number[]; @@ -78,12 +79,14 @@ export class Swiss implements Generator { } getPendingBracketData(players: TournamentPlayer[]): BracketData { + // Shows player list return { type: 'tree', + rootNode: null, }; } getBracketData(): BracketData { - if (this.ended) { + if (this.isTournamentEnded()) { return { type: 'table', tableHeaders: { @@ -112,8 +115,8 @@ export class Swiss implements Generator { status.result = match.result; status.score = match.score; } - const pendingChallenge = match.players[0].user.pendingChallenge; - const inProgressMatch = match.players[0].user.inProgressMatch; + const pendingChallenge = match.p1.user.pendingChallenge; + const inProgressMatch = match.p1.user.inProgressMatch; if (pendingChallenge) { status.state = 'challenging'; } else if (inProgressMatch) { @@ -121,10 +124,10 @@ export class Swiss implements Generator { status.room = inProgressMatch.room.roomid; } return [ - {text: match.players[0].user.name}, - {text: match.players[0].getWL()}, - {text: match.players[1]?.user.name || 'BYE'}, - {text: match.players[1]?.getWL() || ''}, + {text: match.p1.user.name}, + {text: match.p1.getWL()}, + {text: match.p2?.user.name || 'BYE'}, + {text: match.p2?.getWL() || ''}, status, ]; }), @@ -132,7 +135,7 @@ export class Swiss implements Generator { } } freezeBracket(players: TournamentPlayer[]) { - this.players = Utils.shuffle(players.map(player => new SwissPlayer(player))); + this.players = players.map(player => new SwissPlayer(player)); this.isBracketFrozen = true; this.rounds = Math.ceil(Math.log2(this.players.length)); this.advanceRound(); @@ -141,15 +144,15 @@ export class Swiss implements Generator { if (!this.isBracketFrozen) return 'BracketNotFrozen'; const player = this.players.find(p => p.user === user)!; - const match = this.matches.find(m => m.players[0] === player || m.players[1] === player); + const match = this.matches.find(m => m.p1 === player || m.p2 === player); if (match && match.state === 'available') { - let opponent; - if (match.players[0] === player) { - opponent = match.players[1]!; + let opponent: SwissPlayer; + if (match.p1 === player) { + opponent = match.p2!; match.result = 'loss'; match.score = [0, 1]; } else { - opponent = match.players[0]; + opponent = match.p1; match.result = 'win'; match.score = [1, 0]; } @@ -167,14 +170,6 @@ export class Swiss implements Generator { user.game.setPlayerUser(user, null); } sortPlayers(finalResults = false) { - // OOWP depends on OWP of all players, so we need to calculate all OWPs first - for (const player of this.players) { - player.recalculateOWP(); - } - for (const player of this.players) { - player.recalculateOOWP(); - } - if (finalResults) { this.players.sort((a, b) => b.user.score - a.user.score || b.owp - a.owp || b.oowp - a.oowp); @@ -218,20 +213,25 @@ export class Swiss implements Generator { } this.currentRound++; - this.sortPlayers(); + // OOWP depends on OWP of all players, so we need to calculate all OWPs first for (const player of this.players) { + player.recalculateOWP(); + } + for (const player of this.players) { + player.recalculateOOWP(); if (player.user.isDisqualified) player.lockResistance = true; } + this.sortPlayers(); const players = this.players.filter(p => !p.user.isDisqualified); const pairedPlayers = new Set(); this.matches = []; // If odd number of players, give a bye to the lowest ranked player who has not had a bye - let byePlayer: SwissPlayer | undefined; + let byePlayer: SwissPlayer | null = null; if (players.length % 2 === 1) { for (let i = players.length - 1; i >= 0; i--) { - if (players[i].bye === 0) { + if (!players[i].bye) { byePlayer = players[i]; pairedPlayers.add(byePlayer); break; @@ -252,14 +252,14 @@ export class Swiss implements Generator { // If we already played everyone, just allow the repair p2 ||= players.slice(i + 1).find(p => !pairedPlayers.has(p)); if (!p2) throw new Error(`Failed to pair player ${p1.user.name}`); - this.matches.push({players: [p1, p2], state: 'available'}); + this.matches.push({p1, p2, state: 'available'}); pairedPlayers.add(p1); pairedPlayers.add(p2); } // Doing this here so the match gets added at the end if (byePlayer) { - this.matches.push({players: [byePlayer, null], state: 'unavailable'}); + this.matches.push({p1: byePlayer, p2: null, state: 'unavailable'}); byePlayer.bye = 1; byePlayer.user.wins++; byePlayer.user.score++; @@ -270,9 +270,9 @@ export class Swiss implements Generator { const matches: [TournamentPlayer, TournamentPlayer][] = []; for (const match of this.matches) { - if (match.state !== 'available') continue; - if (match.players.some(p => p!.user.isBusy)) continue; - matches.push([match.players[0].user, match.players[1]!.user]); + if (match.state !== 'available' || !match.p2) continue; + if (match.p1.user.isBusy || match.p2.user.isBusy) continue; + matches.push([match.p1.user, match.p2.user]); } return matches; } @@ -282,7 +282,7 @@ export class Swiss implements Generator { const p1 = this.players.find(p => p.user === match[0]); const p2 = this.players.find(p => p.user === match[1]); if (!p1 || !p2) return 'UserNotAdded'; - const swissMatch = this.matches.find(m => m.players[0] === p1 && m.players[1] === p2); + const swissMatch = this.matches.find(m => m.p1 === p1 && m.p2 === p2); if (!swissMatch || swissMatch.state !== 'available') return 'InvalidMatch'; switch (result) { From 46c64995c15637c3adfb3f859121ef92b2a05984 Mon Sep 17 00:00:00 2001 From: Karthik99999 Date: Thu, 11 Apr 2024 00:28:14 -0700 Subject: [PATCH 4/4] lint --- server/tournaments/generator-swiss.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/tournaments/generator-swiss.ts b/server/tournaments/generator-swiss.ts index e48ee74330e9..2a0d79ee7cc9 100644 --- a/server/tournaments/generator-swiss.ts +++ b/server/tournaments/generator-swiss.ts @@ -2,9 +2,9 @@ import {Utils} from '../../lib'; import {BracketData, Generator, TournamentPlayer} from './index'; interface Match { - p1: SwissPlayer, + p1: SwissPlayer; /** null for bye */ - p2: SwissPlayer | null, + p2: SwissPlayer | null; state: 'available' | 'finished' | 'unavailable'; result?: string; score?: number[];