diff --git a/modules/puzzle/src/main/PuzzleFinisher.scala b/modules/puzzle/src/main/PuzzleFinisher.scala index 62481b5e19ff7..d2ab5e815dcbf 100644 --- a/modules/puzzle/src/main/PuzzleFinisher.scala +++ b/modules/puzzle/src/main/PuzzleFinisher.scala @@ -78,15 +78,15 @@ final private[puzzle] class PuzzleFinisher( ) (round, none, perf) case None => - val userRating = perf.toRating - val puzzleRating = glicko2.Rating( + val userOldRating = perf.toRating + val puzzleOldRating = glicko2.Rating( puzzle.glicko.rating.atLeast(lila.rating.Glicko.minRating.value), puzzle.glicko.deviation, puzzle.glicko.volatility, puzzle.plays, none ) - updateRatings(userRating, puzzleRating, win) + val (userRating, puzzleRating) = computeRatings(userOldRating, puzzleOldRating, win) userApi .dubiousPuzzle(me.userId, perf) .map: dubiousPuzzleRating => @@ -204,12 +204,16 @@ final private[puzzle] class PuzzleFinisher( def incPuzzlePlays(puzzleId: PuzzleId): Funit = colls.puzzle.map(_.incFieldUnchecked($id(puzzleId), Puzzle.BSONFields.plays)) - private def updateRatings(u1: glicko2.Rating, u2: glicko2.Rating, win: PuzzleWin): Unit = - val results = glicko2.GameRatingPeriodResults( - List( - if win.yes then glicko2.GameResult(u1, u2, false) - else glicko2.GameResult(u2, u1, false) + private def computeRatings(u1: glicko2.Rating, u2: glicko2.Rating, win: PuzzleWin): Set[glicko2.Rating] = + val results = glicko2.RatingPeriodResults[glicko2.BinaryResult]( + u1 -> List( + if win.yes then glicko2.BinaryResult(u2, true) + else glicko2.BinaryResult(u2, false) + ), + u2 -> List( + if win.yes then glicko2.BinaryResult(u1, false) + else glicko2.BinaryResult(u1, true) ) ) - try calculator.updateRatings(results) + try calculator.computeRatings(results) catch case e: Exception => logger.error("finisher", e) diff --git a/modules/rating/src/main/Glicko.scala b/modules/rating/src/main/Glicko.scala index 1dfd238e443e2..40f03bb4a022b 100644 --- a/modules/rating/src/main/Glicko.scala +++ b/modules/rating/src/main/Glicko.scala @@ -6,6 +6,7 @@ import lila.core.perf.Perf import lila.core.rating.Glicko import lila.db.BSON import lila.rating.PerfExt.* +import lila.rating.glicko2.ColorAdvantage object GlickoExt: @@ -80,10 +81,12 @@ object Glicko: // rating that can be lost or gained with a single game val maxRatingDelta = 700 - val system = glicko2.RatingCalculator(glicko2.Tau.default, ratingPeriodsPerDay) + val calculator = glicko2.RatingCalculator(glicko2.Tau.default, ratingPeriodsPerDay) + def calculatorWithAdvantage(advantage: ColorAdvantage) = + glicko2.RatingCalculator(glicko2.Tau.default, ratingPeriodsPerDay, advantage) def liveDeviation(p: Perf, reverse: Boolean): Double = { - system.previewDeviation(p.toRating, nowInstant, reverse) + calculator.previewDeviation(p.toRating, nowInstant, reverse) }.atLeast(minDeviation).atMost(maxDeviation) given glickoHandler: BSONDocumentHandler[Glicko] = new BSON[Glicko]: diff --git a/modules/rating/src/main/glicko2/Rating.scala b/modules/rating/src/main/glicko2/Rating.scala index 4d363df832180..c87782384305a 100644 --- a/modules/rating/src/main/glicko2/Rating.scala +++ b/modules/rating/src/main/glicko2/Rating.scala @@ -2,29 +2,33 @@ package lila.rating.glicko2 // rewrite from java https://github.com/goochjs/glicko2 final class Rating( - var rating: Double, - var ratingDeviation: Double, - var volatility: Double, - var numberOfResults: Int, - var lastRatingPeriodEnd: Option[java.time.Instant] = None + val rating: Double, + val ratingDeviation: Double, + val volatility: Double, + val numberOfResults: Int, + val lastRatingPeriodEnd: Option[java.time.Instant] = None ): import RatingCalculator.* // the following variables are used to hold values temporarily whilst running calculations - private[glicko2] var workingRating: Double = scala.compiletime.uninitialized - private[glicko2] var workingRatingDeviation: Double = scala.compiletime.uninitialized - private[glicko2] var workingVolatility: Double = scala.compiletime.uninitialized + private[glicko2] var workingRating: Double = 0d + private[glicko2] var workingRatingDeviation: Double = 0d + private[glicko2] var workingVolatility: Double = 0d /** Return the average skill value of the player scaled down to the scale used by the algorithm's internal * workings. */ def getGlicko2Rating: Double = convertRatingToGlicko2Scale(this.rating) + def getGlicko2RatingWithAdvantage(advantage: ColorAdvantage): Double = convertRatingToGlicko2Scale( + this.rating + advantage.value + ) + /** Set the average skill value, taking in a value in Glicko2 scale. */ - def setGlicko2Rating(r: Double) = - rating = convertRatingToOriginalGlickoScale(r) + private def setGlicko2Rating(r: Double) = + convertRatingToOriginalGlickoScale(r) /** Return the rating deviation of the player scaled down to the scale used by the algorithm's internal * workings. @@ -33,20 +37,22 @@ final class Rating( /** Set the rating deviation, taking in a value in Glicko2 scale. */ - def setGlicko2RatingDeviation(rd: Double) = - ratingDeviation = convertRatingDeviationToOriginalGlickoScale(rd) + private def setGlicko2RatingDeviation(rd: Double) = + convertRatingDeviationToOriginalGlickoScale(rd) /** Used by the calculation engine, to move interim calculations into their "proper" places. */ def finaliseRating() = - setGlicko2Rating(workingRating) - setGlicko2RatingDeviation(workingRatingDeviation) - volatility = workingVolatility - workingRatingDeviation = 0d - workingRating = 0d - workingVolatility = 0d + Rating( + convertRatingToOriginalGlickoScale(workingRating), + convertRatingDeviationToOriginalGlickoScale(workingRatingDeviation), + workingVolatility, + numberOfResults, + lastRatingPeriodEnd + ) + override def toString = s"$rating / $ratingDeviation / $volatility / $numberOfResults" def incrementNumberOfResults(increment: Int) = - numberOfResults = numberOfResults + increment + Rating(rating, ratingDeviation, volatility, numberOfResults + increment, lastRatingPeriodEnd) diff --git a/modules/rating/src/main/glicko2/RatingCalculator.scala b/modules/rating/src/main/glicko2/RatingCalculator.scala index 89977618f4e09..bf146f84f8a5c 100644 --- a/modules/rating/src/main/glicko2/RatingCalculator.scala +++ b/modules/rating/src/main/glicko2/RatingCalculator.scala @@ -5,6 +5,13 @@ opaque type Tau = Double object Tau extends OpaqueDouble[Tau]: val default: Tau = 0.75d +opaque type ColorAdvantage = Double +object ColorAdvantage extends OpaqueDouble[ColorAdvantage]: + val zero: ColorAdvantage = 0d + val standard: ColorAdvantage = 7.786d + val crazyhouse: ColorAdvantage = 15.171d + extension (c: ColorAdvantage) def negate: ColorAdvantage = -c + opaque type RatingPeriodsPerDay = Double object RatingPeriodsPerDay extends OpaqueDouble[RatingPeriodsPerDay]: val default: RatingPeriodsPerDay = 0d @@ -29,7 +36,8 @@ object RatingCalculator: final class RatingCalculator( tau: Tau = Tau.default, - ratingPeriodsPerDay: RatingPeriodsPerDay = RatingPeriodsPerDay.default + ratingPeriodsPerDay: RatingPeriodsPerDay = RatingPeriodsPerDay.default, + advantage: ColorAdvantage = ColorAdvantage.zero ): import RatingCalculator.* @@ -41,19 +49,19 @@ final class RatingCalculator( private val ratingPeriodsPerMilli: Double = ratingPeriodsPerDay.value * DAYS_PER_MILLI - /**
Run through all players within a resultset and calculate their new ratings.
Players within the - * resultset who did not compete during the rating period will have see their deviation increase (in line - * with Prof Glickman's paper).
Note that this method will clear the results held in the association - * resultset.
+ /** Run through all players within a resultset and calculate their new ratings. Players within the resultset + * who did not compete during the rating period will have see their deviation increase (in line with Prof + * Glickman's paper). Note that this method will clear the results held in the association resultset. * * @param results */ - def updateRatings(results: RatingPeriodResults[?], skipDeviationIncrease: Boolean = false) = - val players = results.getParticipants - players.foreach { player => + def computeRatings( + results: RatingPeriodResults[Result], + skipDeviationIncrease: Boolean = false + ): Set[Rating] = + results.map { case (player, results) => val elapsedRatingPeriods = if skipDeviationIncrease then 0 else 1 - if results.getResults(player).sizeIs > 0 then - calculateNewRating(player, results.getResults(player), elapsedRatingPeriods) + if results.sizeIs > 0 then calculateNewRating(player, results, elapsedRatingPeriods) else // if a player does not compete during the rating period, then only Step 6 applies. // the player's rating and volatility parameters remain the same but deviation increases @@ -64,7 +72,7 @@ final class RatingCalculator( } // now iterate through the participants and confirm their new ratings - players.foreach { _.finaliseRating() } + results.keySet.map { _.finaliseRating() } /** This is the formula defined in step 6. It is also used for players who have not competed during the * rating period. @@ -91,7 +99,7 @@ final class RatingCalculator( * @param results * @param elapsedRatingPeriods */ - def calculateNewRating(player: Rating, results: List[Result], elapsedRatingPeriods: Double): Unit = + def calculateNewRating(player: Rating, results: List[Result], elapsedRatingPeriods: Double): Rating = val phi = player.getGlicko2RatingDeviation val sigma = player.volatility val a = Math.log(Math.pow(sigma, 2)) @@ -175,13 +183,17 @@ final class RatingCalculator( for result <- results do v = v + ((Math.pow(g(result.getOpponent(player).getGlicko2RatingDeviation), 2)) * E( - player.getGlicko2Rating, - result.getOpponent(player).getGlicko2Rating, + player.getGlicko2RatingWithAdvantage(result.getAdvantage(advantage)), + result + .getOpponent(player) + .getGlicko2RatingWithAdvantage(result.getAdvantage(advantage).negate), result.getOpponent(player).getGlicko2RatingDeviation ) * (1.0 - E( - player.getGlicko2Rating, - result.getOpponent(player).getGlicko2Rating, + player.getGlicko2RatingWithAdvantage(result.getAdvantage(advantage)), + result + .getOpponent(player) + .getGlicko2RatingWithAdvantage(result.getAdvantage(advantage).negate), result.getOpponent(player).getGlicko2RatingDeviation ))) 1 / v @@ -202,8 +214,10 @@ final class RatingCalculator( outcomeBasedRating = outcomeBasedRating + (g(result.getOpponent(player).getGlicko2RatingDeviation) * (result.getScore(player) - E( - player.getGlicko2Rating, - result.getOpponent(player).getGlicko2Rating, + player.getGlicko2RatingWithAdvantage(result.getAdvantage(advantage)), + result + .getOpponent(player) + .getGlicko2RatingWithAdvantage(result.getAdvantage(advantage).negate), result.getOpponent(player).getGlicko2RatingDeviation ))) outcomeBasedRating diff --git a/modules/rating/src/main/glicko2/RatingPeriodResults.scala b/modules/rating/src/main/glicko2/RatingPeriodResults.scala index 04773b9518d92..2627c7aabe067 100644 --- a/modules/rating/src/main/glicko2/RatingPeriodResults.scala +++ b/modules/rating/src/main/glicko2/RatingPeriodResults.scala @@ -1,12 +1,8 @@ package lila.rating.glicko2 // rewrite from java https://github.com/goochjs/glicko2 -trait RatingPeriodResults[R <: Result](): - val results: List[R] - def getResults(player: Rating): List[R] = results.filter(_.participated(player)) - def getParticipants: Set[Rating] = results.flatMap(_.players).toSet +type RatingPeriodResults[R <: Result] = Map[Rating, List[R]] -class GameRatingPeriodResults(val results: List[GameResult]) extends RatingPeriodResults[GameResult] - -class FloatingRatingPeriodResults(val results: List[FloatingResult]) - extends RatingPeriodResults[FloatingResult] +object RatingPeriodResults: + def apply[R <: Result](a: (Rating, List[R])) = Map[Rating, List[R]](a) + def apply[R <: Result](a: (Rating, List[R]), b: (Rating, List[R])) = Map[Rating, List[R]](a, b) diff --git a/modules/rating/src/main/glicko2/Result.scala b/modules/rating/src/main/glicko2/Result.scala index 7099c7c1b741d..82ae21515805d 100644 --- a/modules/rating/src/main/glicko2/Result.scala +++ b/modules/rating/src/main/glicko2/Result.scala @@ -1,51 +1,20 @@ package lila.rating.glicko2 -trait Result: +import chess.{ Color, White } - def getScore(player: Rating): Double +// ASSUME score is between unit interval [0.0d, 1.0d] +class Result(opponent: Rating, score: Double): - def getOpponent(player: Rating): Rating + def getAdvantage(advantage: ColorAdvantage): ColorAdvantage = advantage.map(_ / 2.0d) - def participated(player: Rating): Boolean + def getScore(player: Rating): Double = score - def players: List[Rating] + def getOpponent(player: Rating): Rating = opponent -// score from 0 (opponent wins) to 1 (player wins) -class FloatingResult(player: Rating, opponent: Rating, score: Float) extends Result: +class BinaryResult(val opponent: Rating, val win: Boolean) + extends Result(opponent, if win then 1.0d else 0.0d) - def getScore(p: Rating) = if p == player then score else 1 - score +class DuelResult(val opponent: Rating, val score: Double, color: Color) extends Result(opponent, score): - def getOpponent(p: Rating) = if p == player then opponent else player - - def participated(p: Rating) = p == player || p == opponent - - def players = List(player, opponent) - -final class GameResult(winner: Rating, loser: Rating, isDraw: Boolean) extends Result: - private val POINTS_FOR_WIN = 1.0d - private val POINTS_FOR_LOSS = 0.0d - private val POINTS_FOR_DRAW = 0.5d - - def players = List(winner, loser) - - def participated(player: Rating) = player == winner || player == loser - - /** Returns the "score" for a match. - * - * @param player - * @return - * 1 for a win, 0.5 for a draw and 0 for a loss - * @throws IllegalArgumentException - */ - def getScore(player: Rating): Double = - if isDraw then POINTS_FOR_DRAW - else if winner == player then POINTS_FOR_WIN - else if loser == player then POINTS_FOR_LOSS - else throw new IllegalArgumentException("Player did not participate in match"); - - def getOpponent(player: Rating) = - if winner == player then loser - else if loser == player then winner - else throw new IllegalArgumentException("Player did not participate in match"); - - override def toString = s"$winner vs $loser = $isDraw" + override def getAdvantage(advantage: ColorAdvantage): ColorAdvantage = + if color == White then advantage.map(_ / 2.0d) else advantage.map(_ / 2.0d).negate diff --git a/modules/rating/src/test/RatingCalculatorTest.scala b/modules/rating/src/test/RatingCalculatorTest.scala index 8a43074414fa1..6ab1653237838 100644 --- a/modules/rating/src/test/RatingCalculatorTest.scala +++ b/modules/rating/src/test/RatingCalculatorTest.scala @@ -1,6 +1,6 @@ package lila.rating -import chess.{ Black, Color, White } +import chess.{ Black, Color, White, Outcome } import lila.rating.Perf.default import lila.rating.PerfExt.* @@ -8,54 +8,53 @@ import lila.rating.PerfExt.* import glicko2.* class RatingCalculatorTest extends lila.common.LilaTest: - - def updateRatings(wRating: Rating, bRating: Rating, winner: Option[Color]) = + def computeRatings(wRating: Rating, bRating: Rating, winner: Option[Color]) = val result = winner match case Some(chess.White) => Glicko.Result.Win case Some(chess.Black) => Glicko.Result.Loss case None => Glicko.Result.Draw - val results = GameRatingPeriodResults( - List( + val results = RatingPeriodResults[DuelResult]( + wRating -> List( + result match + case Glicko.Result.Win => DuelResult(bRating, 1.0d, White) + case Glicko.Result.Loss => DuelResult(bRating, 0.0d, White) + case Glicko.Result.Draw => DuelResult(bRating, 0.5d, White) + ), + bRating -> List( result match - case Glicko.Result.Draw => GameResult(wRating, bRating, true) - case Glicko.Result.Win => GameResult(wRating, bRating, false) - case Glicko.Result.Loss => GameResult(bRating, wRating, false) + case Glicko.Result.Win => DuelResult(wRating, 0.0d, Black) + case Glicko.Result.Loss => DuelResult(wRating, 1.0d, Black) + case Glicko.Result.Draw => DuelResult(wRating, 0.5d, Black) ) ) - Glicko.system.updateRatings(results, true) + Glicko.calculatorWithAdvantage(ColorAdvantage.standard).var (wr, br) = computeRatings(results, true) test("default deviation: white wins") { - val wr = default.toRating - val br = default.toRating - updateRatings(wr, br, White.some) - assertCloseTo(wr.rating, 1741d, 1d) - assertCloseTo(br.rating, 1258d, 1d) - assertCloseTo(wr.ratingDeviation, 396d, 1d) - assertCloseTo(br.ratingDeviation, 396d, 1d) - assertCloseTo(wr.volatility, 0.0899983, 0.00000001d) - assertCloseTo(br.volatility, 0.0899983, 0.0000001d) + var (wr, br) = computeRatings(wp.toRating, bp.toRating, White.some) + assertCloseTo(wr.rating, 1739d, 0.5d) + assertCloseTo(br.rating, 1261d, 0.5d) + assertCloseTo(wr.ratingDeviation, 396.7003d, 0.0001d) + assertCloseTo(br.ratingDeviation, 396.7003d, 0.0001d) + assertCloseTo(wr.volatility, 0.09000d, 0.00001d) + assertCloseTo(br.volatility, 0.09000d, 0.00001d) } test("default deviation: black wins") { - val wr = default.toRating - val br = default.toRating - updateRatings(wr, br, Black.some) - assertCloseTo(wr.rating, 1258d, 1d) - assertCloseTo(br.rating, 1741d, 1d) - assertCloseTo(wr.ratingDeviation, 396d, 1d) - assertCloseTo(br.ratingDeviation, 396d, 1d) - assertCloseTo(wr.volatility, 0.0899983, 0.00000001d) - assertCloseTo(br.volatility, 0.0899983, 0.0000001d) + var (wr, br) = computeRatings(wp.toRating, bp.toRating, Black.some) + assertCloseTo(wr.rating, 1256d, 0.5d) + assertCloseTo(br.rating, 1744d, 0.5d) + assertCloseTo(wr.ratingDeviation, 396.7003d, 0.0001d) + assertCloseTo(br.ratingDeviation, 396.7003d, 0.0001d) + assertCloseTo(wr.volatility, 0.09000d, 0.00001d) + assertCloseTo(br.volatility, 0.09000d, 0.00001d) } test("default deviation: draw") { - val wr = default.toRating - val br = default.toRating - updateRatings(wr, br, None) - assertCloseTo(wr.rating, 1500d, 1d) - assertCloseTo(br.rating, 1500d, 1d) - assertCloseTo(wr.ratingDeviation, 396d, 1d) - assertCloseTo(br.ratingDeviation, 396d, 1d) - assertCloseTo(wr.volatility, 0.0899954, 0.0000001d) - assertCloseTo(br.volatility, 0.0899954, 0.0000001d) + var (wr, br) = computeRatings(wp.toRating, bp.toRating, None) + assertCloseTo(wr.rating, 1497d, 0.5d) + assertCloseTo(br.rating, 1503d, 0.5d) + assertCloseTo(wr.ratingDeviation, 396.7003d, 0.0001d) + assertCloseTo(br.ratingDeviation, 396.7003d, 0.0001d) + assertCloseTo(wr.volatility, 0.09000d, 0.00001d) + assertCloseTo(br.volatility, 0.09000d, 0.00001d) } val perf = Perf.default.copy(glicko = Glicko.default.copy( @@ -64,47 +63,41 @@ class RatingCalculatorTest extends lila.common.LilaTest: ) ) test("low deviation: white wins") { - val wr = perf.toRating - val br = perf.toRating - updateRatings(wr, br, White.some) - assertCloseTo(wr.rating, 1517d, 1d) - assertCloseTo(br.rating, 1482d, 1d) - assertCloseTo(wr.ratingDeviation, 78d, 1d) - assertCloseTo(br.ratingDeviation, 78d, 1d) - assertCloseTo(wr.volatility, 0.06, 0.00001d) - assertCloseTo(br.volatility, 0.06, 0.00001d) + var (wr, br) = computeRatings(wp.toRating, bp.toRating, White.some) + assertCloseTo(wr.rating, 1517d, 0.5d) + assertCloseTo(br.rating, 1483d, 0.5d) + assertCloseTo(wr.ratingDeviation, 78.0800d, 0.0001d) + assertCloseTo(br.ratingDeviation, 78.0800d, 0.0001d) + assertCloseTo(wr.volatility, 0.06000d, 0.00001d) + assertCloseTo(br.volatility, 0.06000d, 0.00001d) } test("low deviation: black wins") { - val wr = perf.toRating - val br = perf.toRating - updateRatings(wr, br, Black.some) - assertCloseTo(wr.rating, 1482d, 1d) - assertCloseTo(br.rating, 1517d, 1d) - assertCloseTo(wr.ratingDeviation, 78d, 1d) - assertCloseTo(br.ratingDeviation, 78d, 1d) - assertCloseTo(wr.volatility, 0.06, 0.00001d) - assertCloseTo(br.volatility, 0.06, 0.00001d) + var (wr, br) = computeRatings(wp.toRating, bp.toRating, Black.some) + assertCloseTo(wr.rating, 1483d, 0.5d) + assertCloseTo(br.rating, 1517d, 0.5d) + assertCloseTo(wr.ratingDeviation, 78.0800d, 0.0001d) + assertCloseTo(br.ratingDeviation, 78.0800d, 0.0001d) + assertCloseTo(wr.volatility, 0.06000d, 0.00001d) + assertCloseTo(br.volatility, 0.06000d, 0.00001d) } test("low deviation: draw") { - val wr = perf.toRating - val br = perf.toRating - updateRatings(wr, br, None) - assertCloseTo(wr.rating, 1500d, 1d) - assertCloseTo(br.rating, 1500d, 1d) - assertCloseTo(wr.ratingDeviation, 78d, 1d) - assertCloseTo(br.ratingDeviation, 78d, 1d) - assertCloseTo(wr.volatility, 0.06, 0.00001d) - assertCloseTo(br.volatility, 0.06, 0.00001d) + var (wr, br) = computeRatings(wp.toRating, bp.toRating, None) + assertCloseTo(wr.rating, 1500d, 0.5d) + assertCloseTo(br.rating, 1500d, 0.5d) + assertCloseTo(wr.ratingDeviation, 78.0800d, 0.0001d) + assertCloseTo(br.ratingDeviation, 78.0800d, 0.0001d) + assertCloseTo(wr.volatility, 0.06000d, 0.00001d) + assertCloseTo(br.volatility, 0.06000d, 0.00001d) } { - val wP = Perf.default.copy(glicko = + val wp.toRating = Perf.default.copy(glicko = Glicko.default.copy( rating = 1400, deviation = 79, volatility = 0.06 ) ) - val bP = Perf.default.copy(glicko = + val bp.toRating = Perf.default.copy(glicko = Glicko.default.copy( rating = 1550, deviation = 110, @@ -112,48 +105,42 @@ class RatingCalculatorTest extends lila.common.LilaTest: ) ) test("mixed ratings and deviations: white wins") { - val wr = wP.toRating - val br = bP.toRating - updateRatings(wr, br, White.some) - assertCloseTo(wr.rating, 1422d, 1d) - assertCloseTo(br.rating, 1506d, 1d) - assertCloseTo(wr.ratingDeviation, 77d, 1d) - assertCloseTo(br.ratingDeviation, 105d, 1d) - assertCloseTo(wr.volatility, 0.06, 0.00001d) - assertCloseTo(br.volatility, 0.065, 0.00001d) + var (wr, br) = computeRatings(wp.toRating, bp.toRating, White.some) + assertCloseTo(wr.rating, 1422d, 0.5d) + assertCloseTo(br.rating, 1507d, 0.5d) + assertCloseTo(wr.ratingDeviation, 77.4720d, 0.0001d) + assertCloseTo(br.ratingDeviation, 105.8046d, 0.0001d) + assertCloseTo(wr.volatility, 0.06000d, 0.00001d) + assertCloseTo(br.volatility, 0.06500d, 0.00001d) } test("mixed ratings and deviations: black wins") { - val wr = wP.toRating - val br = bP.toRating - updateRatings(wr, br, Black.some) - assertCloseTo(wr.rating, 1389d, 1d) - assertCloseTo(br.rating, 1568d, 1d) - assertCloseTo(wr.ratingDeviation, 78d, 1d) - assertCloseTo(br.ratingDeviation, 105d, 1d) - assertCloseTo(wr.volatility, 0.06, 0.00001d) - assertCloseTo(br.volatility, 0.065, 0.00001d) + var (wr, br) = computeRatings(wp.toRating, bp.toRating, Black.some) + assertCloseTo(wr.rating, 1390d, 0.5d) + assertCloseTo(br.rating, 1569d, 0.5d) + assertCloseTo(wr.ratingDeviation, 77.4720d, 0.0001d) + assertCloseTo(br.ratingDeviation, 105.8046d, 0.0001d) + assertCloseTo(wr.volatility, 0.06000d, 0.00001d) + assertCloseTo(br.volatility, 0.06500d, 0.00001d) } test("mixed ratings and deviations: draw") { - val wr = wP.toRating - val br = bP.toRating - updateRatings(wr, br, None) - assertCloseTo(wr.rating, 1406d, 1d) - assertCloseTo(br.rating, 1537d, 1d) - assertCloseTo(wr.ratingDeviation, 78d, 1d) - assertCloseTo(br.ratingDeviation, 105.87d, 0.01d) - assertCloseTo(wr.volatility, 0.06, 0.00001d) - assertCloseTo(br.volatility, 0.065, 0.00001d) + var (wr, br) = computeRatings(wp.toRating, bp.toRating, None) + assertCloseTo(wr.rating, 1406d, 0.5d) + assertCloseTo(br.rating, 1538d, 0.5d) + assertCloseTo(wr.ratingDeviation, 77.4720d, 0.0001d) + assertCloseTo(br.ratingDeviation, 105.8046d, 0.0001d) + assertCloseTo(wr.volatility, 0.06000d, 0.00001d) + assertCloseTo(br.volatility, 0.06500d, 0.00001d) } } { - val wP = Perf.default.copy(glicko = + val wp.toRating = Perf.default.copy(glicko = Glicko.default.copy( rating = 1200, deviation = 60, volatility = 0.053 ) ) - val bP = Perf.default.copy(glicko = + val bp.toRating = Perf.default.copy(glicko = Glicko.default.copy( rating = 1850, deviation = 200, @@ -161,36 +148,30 @@ class RatingCalculatorTest extends lila.common.LilaTest: ) ) test("more mixed ratings and deviations: white wins") { - val wr = wP.toRating - val br = bP.toRating - updateRatings(wr, br, White.some) - assertCloseTo(wr.rating, 1216.7d, 0.1d) - assertCloseTo(br.rating, 1636d, 0.1d) - assertCloseTo(wr.ratingDeviation, 59.9d, 0.1d) - assertCloseTo(br.ratingDeviation, 196.9d, 0.1d) - assertCloseTo(wr.volatility, 0.053013, 0.000001d) - assertCloseTo(br.volatility, 0.062028, 0.000001d) + var (wr, br) = computeRatings(wp.toRating, bp.toRating, White.some) + assertCloseTo(wr.rating, 1217d, 0.5d) + assertCloseTo(br.rating, 1637d, 0.5d) + assertCloseTo(wr.ratingDeviation, 59.8971d, 0.0001d) + assertCloseTo(br.ratingDeviation, 196.8617d, 0.0001d) + assertCloseTo(wr.volatility, 0.053013d, 0.00001d) + assertCloseTo(br.volatility, 0.062028d, 0.00001d) } test("more mixed ratings and deviations: black wins") { - val wr = wP.toRating - val br = bP.toRating - updateRatings(wr, br, Black.some) - assertCloseTo(wr.rating, 1199.3d, 0.1d) - assertCloseTo(br.rating, 1855.4d, 0.1d) - assertCloseTo(wr.ratingDeviation, 59.9d, 0.1d) - assertCloseTo(br.ratingDeviation, 196.9d, 0.1d) - assertCloseTo(wr.volatility, 0.052999, 0.000001d) - assertCloseTo(br.volatility, 0.061999, 0.000001d) + var (wr, br) = computeRatings(wp.toRating, bp.toRating, Black.some) + assertCloseTo(wr.rating, 1199d, 0.5d) + assertCloseTo(br.rating, 1856d, 0.5d) + assertCloseTo(wr.ratingDeviation, 59.8971d, 0.0001d) + assertCloseTo(br.ratingDeviation, 196.8617d, 0.0001d) + assertCloseTo(wr.volatility, 0.052999d, 0.00001d) + assertCloseTo(br.volatility, 0.061999d, 0.00001d) } test("more mixed ratings and deviations: draw") { - val wr = wP.toRating - val br = bP.toRating - updateRatings(wr, br, None) - assertCloseTo(wr.rating, 1208.0, 0.1d) - assertCloseTo(br.rating, 1745.7, 0.1d) - assertCloseTo(wr.ratingDeviation, 59.90056, 0.1d) - assertCloseTo(br.ratingDeviation, 196.98729, 0.1d) - assertCloseTo(wr.volatility, 0.053002, 0.000001d) - assertCloseTo(br.volatility, 0.062006, 0.000001d) + var (wr, br) = computeRatings(wp.toRating, bp.toRating, None) + assertCloseTo(wr.rating, 1208d, 0.5d) + assertCloseTo(br.rating, 1746d, 0.5d) + assertCloseTo(wr.ratingDeviation, 59.8971d, 0.0001d) + assertCloseTo(br.ratingDeviation, 196.8617d, 0.0001d) + assertCloseTo(wr.volatility, 0.052999d, 0.00001d) + assertCloseTo(br.volatility, 0.062006d, 0.00001d) } } diff --git a/modules/round/src/main/PerfsUpdater.scala b/modules/round/src/main/PerfsUpdater.scala index ad49ff19feb18..9d2a1b051af43 100644 --- a/modules/round/src/main/PerfsUpdater.scala +++ b/modules/round/src/main/PerfsUpdater.scala @@ -1,11 +1,11 @@ package lila.round - -import chess.{ ByColor, Color, Speed } +import chess.{ ByColor, Color, Speed, Outcome } import lila.core.perf.{ UserPerfs, UserWithPerfs } import lila.rating.GlickoExt.average import lila.rating.PerfExt.{ addOrReset, toRating } -import lila.rating.{ Glicko, PerfType, RatingFactors, RatingRegulator, glicko2 } +import lila.rating.glicko2.{ DuelResult, RatingPeriodResults, Rating, ColorAdvantage } +import lila.rating.{ Glicko, PerfType, RatingFactors, RatingRegulator } import lila.user.{ RankingApi, UserApi } final class PerfsUpdater( @@ -35,35 +35,35 @@ final class PerfsUpdater( val ratingsB = mkRatings(black.perfs) game.ratingVariant match case chess.variant.Chess960 => - updateRatings(ratingsW.chess960, ratingsB.chess960, game) + computeRatings(ratingsW.chess960, ratingsB.chess960, game) case chess.variant.KingOfTheHill => - updateRatings(ratingsW.kingOfTheHill, ratingsB.kingOfTheHill, game) + computeRatings(ratingsW.kingOfTheHill, ratingsB.kingOfTheHill, game) case chess.variant.ThreeCheck => - updateRatings(ratingsW.threeCheck, ratingsB.threeCheck, game) + computeRatings(ratingsW.threeCheck, ratingsB.threeCheck, game) case chess.variant.Antichess => - updateRatings(ratingsW.antichess, ratingsB.antichess, game) + computeRatings(ratingsW.antichess, ratingsB.antichess, game) case chess.variant.Atomic => - updateRatings(ratingsW.atomic, ratingsB.atomic, game) + computeRatings(ratingsW.atomic, ratingsB.atomic, game) case chess.variant.Horde => - updateRatings(ratingsW.horde, ratingsB.horde, game) + computeRatings(ratingsW.horde, ratingsB.horde, game, ColorAdvantage.zero) case chess.variant.RacingKings => - updateRatings(ratingsW.racingKings, ratingsB.racingKings, game) + computeRatings(ratingsW.racingKings, ratingsB.racingKings, game, ColorAdvantage.zero) case chess.variant.Crazyhouse => - updateRatings(ratingsW.crazyhouse, ratingsB.crazyhouse, game) + computeRatings(ratingsW.crazyhouse, ratingsB.crazyhouse, game, ColorAdvantage.crazyhouse) case chess.variant.Standard => game.speed match case Speed.Bullet => - updateRatings(ratingsW.bullet, ratingsB.bullet, game) + computeRatings(ratingsW.bullet, ratingsB.bullet, game) case Speed.Blitz => - updateRatings(ratingsW.blitz, ratingsB.blitz, game) + computeRatings(ratingsW.blitz, ratingsB.blitz, game) case Speed.Rapid => - updateRatings(ratingsW.rapid, ratingsB.rapid, game) + computeRatings(ratingsW.rapid, ratingsB.rapid, game) case Speed.Classical => - updateRatings(ratingsW.classical, ratingsB.classical, game) + computeRatings(ratingsW.classical, ratingsB.classical, game) case Speed.Correspondence => - updateRatings(ratingsW.correspondence, ratingsB.correspondence, game) + computeRatings(ratingsW.correspondence, ratingsB.correspondence, game) case Speed.UltraBullet => - updateRatings(ratingsW.ultraBullet, ratingsB.ultraBullet, game) + computeRatings(ratingsW.ultraBullet, ratingsB.ultraBullet, game) case _ => val perfsW = mkPerfs(ratingsW, white -> black, game) val perfsB = mkPerfs(ratingsB, black -> white, game) @@ -89,20 +89,20 @@ final class PerfsUpdater( } private case class Ratings( - chess960: glicko2.Rating, - kingOfTheHill: glicko2.Rating, - threeCheck: glicko2.Rating, - antichess: glicko2.Rating, - atomic: glicko2.Rating, - horde: glicko2.Rating, - racingKings: glicko2.Rating, - crazyhouse: glicko2.Rating, - ultraBullet: glicko2.Rating, - bullet: glicko2.Rating, - blitz: glicko2.Rating, - rapid: glicko2.Rating, - classical: glicko2.Rating, - correspondence: glicko2.Rating + chess960: Rating, + kingOfTheHill: Rating, + threeCheck: Rating, + antichess: Rating, + atomic: Rating, + horde: Rating, + racingKings: Rating, + crazyhouse: Rating, + ultraBullet: Rating, + bullet: Rating, + blitz: Rating, + rapid: Rating, + classical: Rating, + correspondence: Rating ) private def mkRatings(perfs: UserPerfs) = @@ -123,16 +123,28 @@ final class PerfsUpdater( correspondence = perfs.correspondence.toRating ) - private def updateRatings(white: glicko2.Rating, black: glicko2.Rating, game: Game): Unit = - val results = glicko2.GameRatingPeriodResults( - List( + private def computeRatings( + white: Rating, + black: Rating, + game: Game, + advantage: ColorAdvantage = ColorAdvantage.standard + ): Set[Rating] = + val results = RatingPeriodResults[DuelResult]( + white -> List( + game.winnerColor match + case Some(chess.White) => DuelResult(black, 1.0d, chess.White) + case Some(chess.Black) => DuelResult(black, 0.0d, chess.White) + case None => DuelResult(black, 0.5d, chess.White) + ), + black -> List( game.winnerColor match - case None => glicko2.GameResult(white, black, true) - case Some(chess.White) => glicko2.GameResult(white, black, false) - case Some(chess.Black) => glicko2.GameResult(black, white, false) + case Some(chess.White) => DuelResult(white, 0.0d, chess.Black) + case Some(chess.Black) => DuelResult(white, 1.0d, chess.Black) + case None => DuelResult(white, 0.5d, chess.Black) ) ) - try Glicko.system.updateRatings(results, true) + // tuning TAU per game speed may improve accuracy + try Glicko.calculatorWithAdvantage(advantage).computeRatings(results, true) catch case e: Exception => logger.error(s"update ratings #${game.id}", e) private def mkPerfs(ratings: Ratings, users: PairOf[UserWithPerfs], game: Game): UserPerfs = @@ -141,7 +153,7 @@ final class PerfsUpdater( val speed = game.speed val isStd = game.ratingVariant.standard val isHumanVsMachine = player.noBot && opponent.isBot - def addRatingIf(cond: Boolean, perf: Perf, rating: glicko2.Rating) = + def addRatingIf(cond: Boolean, perf: Perf, rating: Rating) = if cond then val p = perf.addOrReset(_.round.error.glicko, s"game ${game.id}")(rating, game.movedAt) if isHumanVsMachine diff --git a/modules/tutor/src/main/TutorGlicko.scala b/modules/tutor/src/main/TutorGlicko.scala index ffa36ff275757..a2dfdcc79ee82 100644 --- a/modules/tutor/src/main/TutorGlicko.scala +++ b/modules/tutor/src/main/TutorGlicko.scala @@ -10,13 +10,11 @@ object TutorGlicko: def scoresRating(perf: Perf, scores: List[(Rating, Score)]): Rating = val calculator = glicko2.RatingCalculator() - val player = perf.toRating - val results = glicko2.FloatingRatingPeriodResults( - scores.map: (rating, score) => - glicko2.FloatingResult(player, glicko2.Rating(rating, 60, 0.06, 10), score) + val results = glicko2.RatingPeriodResults[glicko2.Result]( + perf.toRating -> + scores.map: (rating, score) => + glicko2.Result(glicko2.Rating(rating, 60, 0.06, 10), score) ) - try calculator.updateRatings(results, true) + try calculator.computeRatings(results, true) catch case e: Exception => logger.error("TutorGlicko.scoresRating", e) - - player.rating.toInt diff --git a/modules/user/src/main/UserRepo.scala b/modules/user/src/main/UserRepo.scala index 51e2dc7a8edd2..b59cfe55ee622 100644 --- a/modules/user/src/main/UserRepo.scala +++ b/modules/user/src/main/UserRepo.scala @@ -20,6 +20,10 @@ final class UserRepo(c: Coll)(using Executor) extends lila.core.user.UserRepo(c) import lila.user.{ BSONFields as F } export lila.user.BSONHandlers.given + private def recoverErased(user: Fu[Option[User]]): Fu[Option[User]] = + user.recover: + case _: reactivemongo.api.bson.exceptions.BSONValueNotFoundException => none + def withColl[A](f: Coll => A): A = f(coll) def topNbGame(nb: Int): Fu[List[User]] = @@ -27,11 +31,8 @@ final class UserRepo(c: Coll)(using Executor) extends lila.core.user.UserRepo(c) def byId[U: UserIdOf](u: U): Fu[Option[User]] = u.id.noGhost.so: - coll - .byId[User](u.id) - .recover: - case _: reactivemongo.api.bson.exceptions.BSONValueNotFoundException => - none // probably GDPRed user + recoverErased: + coll.byId[User](u.id) def byIds[U: UserIdOf](us: Iterable[U]): Fu[List[User]] = val ids = us.map(_.id).filter(_.noGhost) @@ -41,7 +42,9 @@ final class UserRepo(c: Coll)(using Executor) extends lila.core.user.UserRepo(c) coll.byIds[User, UserId](ids, _.sec) def enabledById[U: UserIdOf](u: U): Fu[Option[User]] = - u.id.noGhost.so(coll.one[User](enabledSelect ++ $id(u.id))) + u.id.noGhost.so: + recoverErased: + coll.one[User](enabledSelect ++ $id(u.id)) def enabledByIds[U: UserIdOf](us: Iterable[U]): Fu[List[User]] = val ids = us.map(_.id).filter(_.noGhost)