tiebreak
Version:
Calculation of chess tournament tiebreaks compliant with FIDE regulations
182 lines (181 loc) • 6.23 kB
JavaScript
import {
isPaired,
isPlayed,
isVoluntarilyUnplayedRound
} from "./results.js";
var Tiebreak = /* @__PURE__ */ ((Tiebreak2) => {
Tiebreak2["SCORE"] = "SCORE";
Tiebreak2["BUCHHOLZ"] = "BH";
Tiebreak2["BUCHHOLZ_CUT1"] = "BH-C1";
Tiebreak2["BUCHHOLZ_CUT2"] = "BH-C2";
Tiebreak2["BUCHHOLZ_MEDIAN1"] = "BH-M1";
Tiebreak2["BUCHHOLZ_MEDIAN2"] = "BH-M2";
return Tiebreak2;
})(Tiebreak || {});
var UnplayedRoundsAdjustment = /* @__PURE__ */ ((UnplayedRoundsAdjustment2) => {
UnplayedRoundsAdjustment2["NONE"] = "NONE";
UnplayedRoundsAdjustment2["FIDE_2023"] = "FIDE_2023";
UnplayedRoundsAdjustment2["FIDE_2009"] = "FIDE_2009";
return UnplayedRoundsAdjustment2;
})(UnplayedRoundsAdjustment || {});
class Tiebreaker {
constructor(results, unplayedRoundsAdjustment) {
this.results = results;
this.unplayedRoundsAdjustment = unplayedRoundsAdjustment;
}
/**
* Calculates a full ranking based on the given tiebreaks. The returned array is sorted
* by rank.
*/
ranking(round, tiebreaks) {
const ranking = this.results.allPlayers().map((playerId) => {
const scores = tiebreaks.map((t) => this.tiebreak(t, playerId, round));
return { playerId, scores, rank: 1 };
});
const compareScores = (a, b) => {
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;
};
ranking.sort((a, b) => {
const cmp = compareScores(a, b);
return cmp === 0 ? a.playerId.toString().localeCompare(b.playerId.toString()) : cmp;
});
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.
*/
tiebreak(tiebreak, player, round) {
switch (tiebreak) {
case "SCORE" /* SCORE */:
return this.score(player, round);
case "BH" /* BUCHHOLZ */:
return this.buchholz(player, round);
case "BH-C1" /* BUCHHOLZ_CUT1 */:
return this.buchholz(player, round, 1, 0);
case "BH-C2" /* BUCHHOLZ_CUT2 */:
return this.buchholz(player, round, 2, 0);
case "BH-M1" /* BUCHHOLZ_MEDIAN1 */:
return this.buchholz(player, round, 1, 1);
case "BH-M2" /* BUCHHOLZ_MEDIAN2 */:
return this.buchholz(player, round, 2, 2);
}
}
/**
* Returns the total points the given player scored by the given round.
*/
score(player, round) {
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.
*/
adjustedScore(player, round) {
switch (this.unplayedRoundsAdjustment) {
case "NONE" /* NONE */:
return this.score(player, round);
case "FIDE_2023" /* FIDE_2023 */: {
const lastAvailableToPlayRound = this.lastAvailableToPlayRound(player, round);
return this.score(player, lastAvailableToPlayRound) + (round - lastAvailableToPlayRound) * 0.5;
}
case "FIDE_2009" /* FIDE_2009 */:
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.
*/
lastAvailableToPlayRound(player, maxRound) {
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.
*/
adjustedGames(player, round) {
return this.results.getAll(player, round).map((result, index) => {
const game = {
round: index + 1,
gameScore: this.scoreForResult(result),
opponentScore: isPaired(result) ? this.adjustedScore(result.opponent, round) : 0,
isVur: isVoluntarilyUnplayedRound(result)
};
if (!isPlayed(result)) {
if (this.unplayedRoundsAdjustment === "FIDE_2023" /* FIDE_2023 */) {
game.opponentScore = this.score(player, round);
}
if (this.unplayedRoundsAdjustment === "FIDE_2009" /* 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.
*/
buchholz(player, round, cutLowest = 0, cutHighest = 0) {
let games = this.adjustedGames(player, round);
if (cutLowest || cutHighest) {
games.sort((a, b) => {
if (this.unplayedRoundsAdjustment === "FIDE_2023" /* 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));
}
// TODO: Maybe turn PlayerResult into a class which returns the score?
scoreForResult(result) {
switch (result) {
case "unpaired":
return 0;
case "allocated-bye":
return 1;
case "half-point-bye":
return 0.5;
default:
return result.score;
}
}
sum(numbers) {
return numbers.reduce((prev, curr) => prev + curr, 0);
}
}
export {
Tiebreak,
Tiebreaker,
UnplayedRoundsAdjustment
};
//# sourceMappingURL=tiebreak.js.map