Skip to content

Commit 8bc14ab

Browse files
Merge pull request #3 from marceljuenemann/bh-cut
Buchholz with modifiers (Cut-1 etc.)
2 parents aa1ff46 + 9fd6a12 commit 8bc14ab

File tree

4 files changed

+139
-77
lines changed

4 files changed

+139
-77
lines changed

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@ Tiebreak is a TypeScript library for calculating various tournament tiebreak sco
1111
**Calculation of the following tiebreakers:**
1212

1313
- Buchholz
14-
- (more to come!)
14+
- Buchholz with modifiers (Cut-1, Cut-2, Median-1 etc.)
15+
- (more to come)
1516

1617
**Configurable adjustments for unplayed games:**
1718

src/__tests__/testcases/README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
# Test Cases
2+
3+
**fide-exercise-2023.csv:**
4+
Exercises for the new FIDE regulations published on 2024-03-18 at https://tec.fide.com/2024/03/18/tie-break-exercise/

src/__tests__/tiebreak.test.ts

Lines changed: 53 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { describe, expect, it } from "vitest"
22

33
import { Results, RoundResults, Score, TournamentType } from "../results.js"
4-
import { Modifier, Tiebreak, Tiebreaker, UnplayedRoundsAdjustment } from "../tiebreak.js"
4+
import { Tiebreak, Tiebreaker, UnplayedRoundsAdjustment } from "../tiebreak.js"
55
import { readTestCases } from "./util/test-case-reader.js"
66

77
describe("TiebreakCalculation", () => {
@@ -118,6 +118,39 @@ describe("TiebreakCalculation", () => {
118118
expect(tiebreak.buchholz("16", 5)).toEqual(12.5)
119119
expect(tiebreak.buchholz("4", 5)).toEqual(15.0)
120120
})
121+
122+
it("should pass FIDE exercise 5 (2023)", async () => {
123+
const rounds = await readTestCases("fide-exercise-2023")
124+
const tiebreak = new Tiebreaker(rounds, UnplayedRoundsAdjustment.FIDE_2023)
125+
expect(tiebreak.buchholz("5", 5, 1)).toEqual(7.5)
126+
expect(tiebreak.buchholz("8", 5, 1)).toEqual(12)
127+
expect(tiebreak.buchholz("11", 5, 1)).toEqual(12)
128+
})
129+
130+
it("should pass FIDE exercise 6 (2023)", async () => {
131+
const rounds = await readTestCases("fide-exercise-2023")
132+
const tiebreak = new Tiebreaker(rounds, UnplayedRoundsAdjustment.FIDE_2023)
133+
expect(tiebreak.buchholz("7", 5, 1)).toEqual(12.5)
134+
expect(tiebreak.buchholz("9", 5, 1)).toEqual(7.5)
135+
expect(tiebreak.buchholz("13", 5, 1)).toEqual(12)
136+
})
137+
138+
it("should pass FIDE exercise 7 (2023)", async () => {
139+
const rounds = await readTestCases("fide-exercise-2023")
140+
const tiebreak = new Tiebreaker(rounds, UnplayedRoundsAdjustment.FIDE_2023)
141+
expect(tiebreak.buchholz("1", 5, 1)).toEqual(11.0)
142+
expect(tiebreak.buchholz("3", 5, 1)).toEqual(13)
143+
expect(tiebreak.buchholz("4", 5, 1)).toEqual(11.5)
144+
expect(tiebreak.buchholz("16", 5, 1)).toEqual(11)
145+
})
146+
147+
it("should pass FIDE exercise 8 (2023)", async () => {
148+
const rounds = await readTestCases("fide-exercise-2023")
149+
const tiebreak = new Tiebreaker(rounds, UnplayedRoundsAdjustment.FIDE_2023)
150+
expect(tiebreak.buchholz("12", 5, 1)).toEqual(9.5)
151+
expect(tiebreak.buchholz("14", 5, 1)).toEqual(9)
152+
expect(tiebreak.buchholz("15", 5, 1)).toEqual(11)
153+
})
121154
})
122155

123156
describe("with FIDE_2009 unplayed rounds adjustment", () => {
@@ -154,18 +187,20 @@ describe("TiebreakCalculation", () => {
154187
expect(tiebreak.buchholz("B", 3)).toEqual(1 + 2 * 0.5 + (0 + 1 + 0.5) + (0 + 1))
155188
})
156189
})
157-
})
158190

159-
describe("buchholz with Cut-1 modifier", () => {
160-
describe("with FIDE_2023 unplayed rounds adjustment", () => {
161-
it("should pass FIDE exercise 5 (2023)", async () => {
162-
const rounds = await readTestCases("fide-exercise-2023")
163-
const tiebreak = new Tiebreaker(rounds, UnplayedRoundsAdjustment.FIDE_2023)
164-
expect(tiebreak.buchholz("5", 5, Modifier.CUT_1)).toEqual(7.5)
165-
expect(tiebreak.buchholz("8", 5, Modifier.CUT_1)).toEqual(12)
166-
expect(tiebreak.buchholz("11", 5, Modifier.CUT_1)).toEqual(12)
167-
})
191+
/*
192+
TODO: Test more modifiers with old FIDE unplayed rounds adjustments
193+
- Cut-1
194+
- Median-1
195+
- Cut-2
196+
- Very high cut numbers
197+
198+
describe("with modifiers", () => {
199+
200+
201+
168202
})
203+
*/
169204
})
170205

171206
describe("ranking", () => {
@@ -185,11 +220,13 @@ describe("TiebreakCalculation", () => {
185220
},
186221
])
187222
const tiebreak = new Tiebreaker(results, UnplayedRoundsAdjustment.FIDE_2023)
188-
expect(tiebreak.ranking(2, [Tiebreak.SCORE, Tiebreak.BUCHHOLZ])).toEqual([
189-
{ playerId: "B", rank: 1, scores: [2, 2.5] }, // BH: 2 for unplayed + 0.5 for A
190-
{ playerId: "C", rank: 2, scores: [1.5, 2] }, // BH: 0.5 for A + 1.5 for bye
191-
{ playerId: "A", rank: 3, scores: [0.5, 3.5] }, // BH: 1.5 for C + 2 for B
192-
{ playerId: "D", rank: 4, scores: [0, 0] }, // BH: 2 * 0 for unplayed rounds
223+
expect(
224+
tiebreak.ranking(2, [Tiebreak.SCORE, Tiebreak.BUCHHOLZ, Tiebreak.BUCHHOLZ_CUT1]),
225+
).toEqual([
226+
{ playerId: "B", rank: 1, scores: [2, 2.5, 2] }, // BH: 2 for unplayed + 0.5 for A
227+
{ playerId: "C", rank: 2, scores: [1.5, 2, 1.5] }, // BH: 0.5 for A + 1.5 for bye
228+
{ playerId: "A", rank: 3, scores: [0.5, 3.5, 2] }, // BH: 1.5 for C + 2 for B
229+
{ playerId: "D", rank: 4, scores: [0, 0, 0] }, // BH: 2 * 0 for unplayed rounds
193230
])
194231
})
195232

src/tiebreak.ts

Lines changed: 80 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,16 @@ export enum Tiebreak {
1717
* The sum of the scores of each of the opponents of a participant.
1818
*/
1919
BUCHHOLZ = "BH",
20+
21+
/**
22+
* Buchholz with the least significant opponent cut. Note that voluntarily
23+
* unplayed rounds will be cut first if FIDE_2023 regulations are applied.
24+
*/
25+
BUCHHOLZ_CUT1 = "BH-C1",
26+
27+
BUCHHOLZ_CUT2 = "BH-C2",
28+
BUCHHOLZ_MEDIAN1 = "BH-M1",
29+
BUCHHOLZ_MEDIAN2 = "BH-M2",
2030
}
2131

2232
export enum UnplayedRoundsAdjustment {
@@ -46,31 +56,19 @@ export enum UnplayedRoundsAdjustment {
4656
FIDE_2009 = "FIDE_2009",
4757
}
4858

49-
/**
50-
* Modifiers for tiebreaks that are based on a sum of values, such as Buchholz
51-
* and Sonneborn-Berger. These modifiers are defined in the FIDE Tiebreak Regulations,
52-
* Article 14.
53-
*/
54-
export enum Modifier {
55-
// 14.1 Cut-1: Cut the Least Significant Value
56-
CUT_1 = "Cut-1",
57-
58-
// 14.2 Cut-2: Cut the two Least Significant Values
59-
CUT_2 = "Cut-2",
60-
61-
// 14.3 Median­1: Cut the Least and the Most Significant Values (in that order)
62-
MEDIAN_1 = "Median1",
63-
64-
// 14.4 Median­2: Cut the two Least and the two Most Significant Values (in that order)
65-
MEDIAN_2 = "Median2",
66-
}
67-
6859
export interface PlayerRanking {
6960
rank: number
7061
playerId: PlayerId
7162
scores: number[]
7263
}
7364

65+
interface AdjustedGame {
66+
round: number
67+
gameScore: number
68+
opponentScore: number
69+
isVur: boolean
70+
}
71+
7472
/**
7573
* Calculates tiebreaks for a tournament with the given results and configuration.
7674
*/
@@ -130,6 +128,14 @@ export class Tiebreaker {
130128

131129
case Tiebreak.BUCHHOLZ:
132130
return this.buchholz(player, round)
131+
case Tiebreak.BUCHHOLZ_CUT1:
132+
return this.buchholz(player, round, 1, 0)
133+
case Tiebreak.BUCHHOLZ_CUT2:
134+
return this.buchholz(player, round, 2, 0)
135+
case Tiebreak.BUCHHOLZ_MEDIAN1:
136+
return this.buchholz(player, round, 1, 1)
137+
case Tiebreak.BUCHHOLZ_MEDIAN2:
138+
return this.buchholz(player, round, 2, 2)
133139
}
134140
}
135141

@@ -186,36 +192,64 @@ export class Tiebreaker {
186192
}
187193

188194
/**
189-
* Buchholz score. Note that unplayed games are adjusted according to the configured UnplayedRoundsAdjustment.
195+
* Returns all opponents of the given player with their adjusted scores for the purpose of
196+
* calculating tiebreaks like Buchholz and SoBerg.
190197
*/
191-
public buchholz(player: PlayerId, round: number, modifier?: Modifier): number {
192-
const opponentScores = this.results.getAll(player, round).map((result, index) => {
193-
const currentRound = index + 1
194-
switch (this.unplayedRoundsAdjustment) {
195-
case UnplayedRoundsAdjustment.NONE:
196-
return isPaired(result) ? this.adjustedScore(result.opponent, round) : 0
197-
198-
case UnplayedRoundsAdjustment.FIDE_2023:
199-
if (isPlayed(result)) {
200-
return this.adjustedScore(result.opponent, round)
201-
} else {
202-
// Use a dummy opponent with same score.
203-
return this.score(player, round)
204-
}
205-
206-
case UnplayedRoundsAdjustment.FIDE_2009:
207-
if (isPlayed(result)) {
208-
return this.adjustedScore(result.opponent, round)
209-
} else {
210-
// Use a virtual opponent.
211-
const initialScore = this.score(player, currentRound - 1)
212-
const gameScore = 1 - this.scoreForResult(result)
213-
const virtualPoints = (round - currentRound) * 0.5
214-
return initialScore + gameScore + virtualPoints
215-
}
198+
private adjustedGames(player: PlayerId, round: number): AdjustedGame[] {
199+
return this.results.getAll(player, round).map((result, index) => {
200+
const game: AdjustedGame = {
201+
round: index + 1,
202+
gameScore: this.scoreForResult(result),
203+
opponentScore: isPaired(result) ? this.adjustedScore(result.opponent, round) : 0,
204+
isVur: isVoluntarilyUnplayedRound(result),
205+
}
206+
207+
// Apply unplayed round adjustments.
208+
if (!isPlayed(result)) {
209+
// 2023: Use a dummy opponent with same score as the player.
210+
if (this.unplayedRoundsAdjustment === UnplayedRoundsAdjustment.FIDE_2023) {
211+
game.opponentScore = this.score(player, round)
212+
}
213+
214+
// 2009: Use a virtual opponent that starts with the same score.
215+
if (this.unplayedRoundsAdjustment === UnplayedRoundsAdjustment.FIDE_2009) {
216+
const initialScore = this.score(player, game.round - 1)
217+
const opponentGameScore = 1 - game.gameScore
218+
const virtualPoints = (round - game.round) * 0.5
219+
game.opponentScore = initialScore + opponentGameScore + virtualPoints
220+
}
216221
}
222+
return game
217223
})
218-
return this.sumWithModifier(opponentScores, modifier)
224+
}
225+
226+
/**
227+
* Buchholz score. Note that unplayed games are adjusted according to the configured UnplayedRoundsAdjustment.
228+
*/
229+
public buchholz(
230+
player: PlayerId,
231+
round: number,
232+
cutLowest: number = 0,
233+
cutHighest: number = 0,
234+
): number {
235+
let games = this.adjustedGames(player, round)
236+
237+
if (cutLowest || cutHighest) {
238+
games.sort((a, b) => {
239+
// Since 2023, voluntarily unplayed rounds should get cut first.
240+
if (
241+
this.unplayedRoundsAdjustment === UnplayedRoundsAdjustment.FIDE_2023 &&
242+
a.isVur !== b.isVur
243+
) {
244+
return Number(a.isVur) - Number(b.isVur)
245+
}
246+
return b.opponentScore - a.opponentScore
247+
})
248+
249+
games = games.slice(cutHighest, -cutLowest)
250+
}
251+
252+
return this.sum(games.map((g) => g.opponentScore))
219253
}
220254

221255
// TODO: Maybe turn PlayerResult into a class which returns the score?
@@ -232,20 +266,6 @@ export class Tiebreaker {
232266
}
233267
}
234268

235-
/**
236-
* Applies the given modifier to the values (e.g. cutting least significant values)
237-
* and returns the sum of the remaining values.
238-
*/
239-
private sumWithModifier(values: number[], modifier?: Modifier): number {
240-
values.sort()
241-
switch (modifier) {
242-
case Modifier.CUT_1:
243-
values = values.splice(1)
244-
break
245-
}
246-
return this.sum(values)
247-
}
248-
249269
private sum(numbers: number[]): number {
250270
return numbers.reduce((prev, curr) => prev + curr, 0)
251271
}

0 commit comments

Comments
 (0)