UNPKG

tiebreak

Version:

Calculation of chess tournament tiebreaks compliant with FIDE regulations

291 lines (257 loc) 9.46 kB
import { PlayerId, PlayerResult, Results, Score, isPaired, isPlayed, isVoluntarilyUnplayedRound, } from "./results.js" export enum Tiebreak { /** * The overall score of the player, i.e. 1 point for each win and 0.5 points for each draw. */ SCORE = "SCORE", /** * The sum of the scores of each of the opponents of a participant. */ BUCHHOLZ = "BH", /** * Buchholz with the least significant opponent cut. Note that voluntarily * unplayed rounds will be cut first if FIDE_2023 regulations are applied. */ BUCHHOLZ_CUT1 = "BH-C1", BUCHHOLZ_CUT2 = "BH-C2", BUCHHOLZ_MEDIAN1 = "BH-M1", BUCHHOLZ_MEDIAN2 = "BH-M2", /** * Calculated by adding, for each round, a value given by multiplying the final * score of the opponents by the points scored against them. */ SONNEBORN_BERGER = "SB", } export enum UnplayedRoundsAdjustment { /** * Any unplayed games are counted according to the points that were scored. * No "virtual opponents" are created when a player was not paired against * a real player. */ NONE = "NONE", /** * For the most part, the 2023 regulations treat unplayed rounds the same as played rounds, * e.g. Buchholz is usually just the sum of the opponent's score. However, opponent's who * withdrew from a tournament will have their score adjusted by counting all rounds after the * withdrawal as 0.5 points. In addition, the player's own unplayed rounds are counted as a * game against an opponent that scored the same number of points at the end of the tournament. */ FIDE_2023 = "FIDE_2023", /** * Adjustments according to FIDE regulations from 2009. If you had opponents who had * unplayed games, those unplayed games are always counted as a draw. If you had * unplayed games yourself, your Buchholz is calculated using a 'virtual opponent'. * The virtual opponent is assumed to have the same points as you initially and * score a draw in all following rounds. */ FIDE_2009 = "FIDE_2009", } export interface PlayerRanking { rank: number playerId: PlayerId scores: number[] } interface AdjustedGame { round: number gameScore: Score opponentScore: number isVur: boolean } /** * Calculates tiebreaks for a tournament with the given results and configuration. */ // TODO: Measure performance for large tournaments. Add caching/memoization if needed. export class Tiebreaker { constructor( private results: Results, private unplayedRoundsAdjustment: UnplayedRoundsAdjustment, ) {} /** * Calculates a full ranking based on the given tiebreaks. The returned array is sorted * by rank. */ public ranking(round: number, tiebreaks: Tiebreak[]): PlayerRanking[] { // Calculate all tiebreak scores. const ranking = this.results.allPlayers().map((playerId) => { const scores = tiebreaks.map((t) => this.tiebreak(t, playerId, round)) return { playerId, scores, rank: 1 } }) // Comparison function for scores. const compareScores = (a: PlayerRanking, b: PlayerRanking) => { for (let i = 0; i < a.scores.length && i < b.scores.length; i++) { if (a.scores[i] !== b.scores[i]) { return b.scores[i] - a.scores[i] } } return 0 } // Sort by scores and playerId. ranking.sort((a, b) => { const cmp = compareScores(a, b) return cmp === 0 ? a.playerId.toString().localeCompare(b.playerId.toString()) : cmp }) // Set rank correctly, possibly with multiple players on the same rank. for (let i = 1; i < ranking.length; i++) { if (compareScores(ranking[i - 1], ranking[i]) === 0) { ranking[i].rank = ranking[i - 1].rank } else { ranking[i].rank = i + 1 } } return ranking } /** * Calculates the specified tiebreak. This is just a more generic short-hand method for calling * tiebreak methods directly. */ public tiebreak(tiebreak: Tiebreak, player: PlayerId, round: number): number { switch (tiebreak) { case Tiebreak.SCORE: return this.score(player, round) case Tiebreak.BUCHHOLZ: return this.buchholz(player, round) case Tiebreak.BUCHHOLZ_CUT1: return this.buchholz(player, round, 1, 0) case Tiebreak.BUCHHOLZ_CUT2: return this.buchholz(player, round, 2, 0) case Tiebreak.BUCHHOLZ_MEDIAN1: return this.buchholz(player, round, 1, 1) case Tiebreak.BUCHHOLZ_MEDIAN2: return this.buchholz(player, round, 2, 2) case Tiebreak.SONNEBORN_BERGER: return this.sonnebornBerger(player, round) } } /** * Returns the total points the given player scored by the given round. */ public score(player: PlayerId, round: number): number { return this.sum( this.results.getAll(player, round).map((result) => { return this.scoreForResult(result) }), ) } /** * Returns the players score with unplayed rounds adjusted according to the configured UnplayedRoundsAdjustment. */ public adjustedScore(player: PlayerId, round: number): number { switch (this.unplayedRoundsAdjustment) { case UnplayedRoundsAdjustment.NONE: return this.score(player, round) case UnplayedRoundsAdjustment.FIDE_2023: { // For the most part, the adjusted score is the same as the normal score, i.e. unplayed rounds are // counted according to the points they scored. However, after a player withdrew (i.e. no later rounds // where they were available to play), all rounds are counted as 0.5. const lastAvailableToPlayRound = this.lastAvailableToPlayRound(player, round) return ( this.score(player, lastAvailableToPlayRound) + (round - lastAvailableToPlayRound) * 0.5 ) } case UnplayedRoundsAdjustment.FIDE_2009: // All unplayed rounds are counted as 0.5 points. return this.sum( this.results.getAll(player, round).map((result) => { return isPlayed(result) ? result.score : 0.5 }), ) } } /** * Returns the highest round number in which the player was "available to play", i.e. they didn't * voluntarily not play the round. Returns 0 if the player was never available to play. */ private lastAvailableToPlayRound(player: PlayerId, maxRound: number) { for (let round = maxRound; round >= 1; round--) { if (!isVoluntarilyUnplayedRound(this.results.get(player, round))) { return round } } return 0 } /** * Returns all opponents of the given player with their adjusted scores for the purpose of * calculating tiebreaks like Buchholz and SoBerg. */ private adjustedGames(player: PlayerId, round: number): AdjustedGame[] { return this.results.getAll(player, round).map((result, index) => { const game: AdjustedGame = { round: index + 1, gameScore: this.scoreForResult(result), opponentScore: isPaired(result) ? this.adjustedScore(result.opponent, round) : 0, isVur: isVoluntarilyUnplayedRound(result), } // Apply unplayed round adjustments. if (!isPlayed(result)) { // 2023: Use a dummy opponent with same score as the player. if (this.unplayedRoundsAdjustment === UnplayedRoundsAdjustment.FIDE_2023) { game.opponentScore = this.score(player, round) } // 2009: Use a virtual opponent that starts with the same score. if (this.unplayedRoundsAdjustment === UnplayedRoundsAdjustment.FIDE_2009) { const initialScore = this.score(player, game.round - 1) const opponentGameScore = 1 - game.gameScore const virtualPoints = (round - game.round) * 0.5 game.opponentScore = initialScore + opponentGameScore + virtualPoints } } return game }) } /** * Buchholz score. Note that unplayed games are adjusted according to the configured UnplayedRoundsAdjustment. */ public buchholz( player: PlayerId, round: number, cutLowest: number = 0, cutHighest: number = 0, ): number { let games = this.adjustedGames(player, round) if (cutLowest || cutHighest) { games.sort((a, b) => { // Since 2023, voluntarily unplayed rounds should get cut first. if ( this.unplayedRoundsAdjustment === UnplayedRoundsAdjustment.FIDE_2023 && a.isVur !== b.isVur ) { return Number(a.isVur) - Number(b.isVur) } return b.opponentScore - a.opponentScore }) games = games.slice(cutHighest, -cutLowest) } return this.sum(games.map((g) => g.opponentScore)) } /** * Sonneborn-Berger score. Note that unplayed games are adjusted according to the configured UnplayedRoundsAdjustment. */ public sonnebornBerger(player: PlayerId, round: number): number { const games = this.adjustedGames(player, round) return this.sum(games.map((g) => g.opponentScore * g.gameScore)) } // TODO: Maybe turn PlayerResult into a class which returns the score? private scoreForResult(result: PlayerResult): Score { switch (result) { case "unpaired": return 0 case "allocated-bye": return 1 case "half-point-bye": return 0.5 default: return result.score } } private sum(numbers: number[]): number { return numbers.reduce((prev, curr) => prev + curr, 0) } }