diff --git a/build.gradle b/build.gradle index e69f440..aab2c1b 100644 --- a/build.gradle +++ b/build.gradle @@ -27,7 +27,7 @@ ext { isReleaseVersion = !(project.version =~ /-SNAPSHOT$/) } -version = "0.2.2" +version = "0.3.0" project.group = "com.github.wakingrufus" dependencies { diff --git a/src/main/kotlin/com/github/wakingrufus/elo/BigDecimal.kt b/src/main/kotlin/com/github/wakingrufus/elo/BigDecimal.kt index 1d107b5..6c4b1ad 100644 --- a/src/main/kotlin/com/github/wakingrufus/elo/BigDecimal.kt +++ b/src/main/kotlin/com/github/wakingrufus/elo/BigDecimal.kt @@ -1,26 +1,26 @@ package com.github.wakingrufus.elo import java.math.BigDecimal +import java.math.MathContext import java.math.RoundingMode -fun pow(base: BigDecimal, exponent: BigDecimal): BigDecimal { - var result = BigDecimal.ZERO +fun BigDecimal.pow( exponent: BigDecimal): BigDecimal { val signOf2 = exponent.signum() // Perform X^(A+B)=X^A*X^B (B = remainder) - val dn1 = base.toDouble() + val dn1 = this.toDouble() // Compare the same row of digits according to context val n2 = exponent.multiply(BigDecimal(signOf2)) // n2 is now positive val remainderOf2 = n2.remainder(BigDecimal.ONE) val n2IntPart = n2.subtract(remainderOf2) // Calculate big part of the power using context - // bigger range and performance but lower accuracy - val intPow = base.pow(n2IntPart.intValueExact()) + val intPow = this.pow(n2IntPart.intValueExact()) val doublePow = BigDecimal(Math.pow(dn1, remainderOf2.toDouble())) - result = intPow.multiply(doublePow) + var result = intPow.multiply(doublePow) // Fix negative power if (signOf2 == -1) - result = BigDecimal.ONE.divide(result, RoundingMode.HALF_UP) + result = BigDecimal.ONE.divide(result, MathContext.DECIMAL64) return result } \ No newline at end of file diff --git a/src/main/kotlin/com/github/wakingrufus/elo/ExpectedScore.kt b/src/main/kotlin/com/github/wakingrufus/elo/ExpectedScore.kt index 15569f1..6da4cc1 100644 --- a/src/main/kotlin/com/github/wakingrufus/elo/ExpectedScore.kt +++ b/src/main/kotlin/com/github/wakingrufus/elo/ExpectedScore.kt @@ -11,7 +11,7 @@ fun calculateExpectedScore(rating1: Int, rating2: Int, xi: Int): BigDecimal { } private fun calculateQ(teamRating: Int, xi: Int): BigDecimal { - return pow(BigDecimal.TEN, BigDecimal(teamRating).divide(BigDecimal(xi), MathContext.DECIMAL32)) + return BigDecimal.TEN.pow(BigDecimal(teamRating).divide(BigDecimal(xi), MathContext.DECIMAL32)) } private fun calculateE(q1: BigDecimal, q2: BigDecimal): BigDecimal { diff --git a/src/main/kotlin/com/github/wakingrufus/elo/League.kt b/src/main/kotlin/com/github/wakingrufus/elo/League.kt index 00d6419..409209b 100644 --- a/src/main/kotlin/com/github/wakingrufus/elo/League.kt +++ b/src/main/kotlin/com/github/wakingrufus/elo/League.kt @@ -4,5 +4,4 @@ data class League(val startingRating: Int = 1500, val xi: Int = 1000, val kFactorBase: Int = 32, val trialPeriod: Int = 10, - val trialKFactorMultiplier: Int = 2, - val teamSize: Int) \ No newline at end of file + val trialKFactorMultiplier: Int = 2) \ No newline at end of file diff --git a/src/main/kotlin/com/github/wakingrufus/elo/Player.kt b/src/main/kotlin/com/github/wakingrufus/elo/Player.kt index 8f24272..cf2c8d8 100644 --- a/src/main/kotlin/com/github/wakingrufus/elo/Player.kt +++ b/src/main/kotlin/com/github/wakingrufus/elo/Player.kt @@ -3,7 +3,7 @@ package com.github.wakingrufus.elo data class Player( val id: String, val currentRating: Int, - val gamesPlayed: Int, - val wins: Int, - val losses: Int + val gamesPlayed: Int = 0, + val wins: Int = 0, + val losses: Int = 0 ) \ No newline at end of file diff --git a/src/main/kotlin/com/github/wakingrufus/elo/PlayerUtil.kt b/src/main/kotlin/com/github/wakingrufus/elo/PlayerUtil.kt index 3e07961..16fc3c4 100644 --- a/src/main/kotlin/com/github/wakingrufus/elo/PlayerUtil.kt +++ b/src/main/kotlin/com/github/wakingrufus/elo/PlayerUtil.kt @@ -1,22 +1,19 @@ package com.github.wakingrufus.elo -fun calculateTeamStartingRating(players: List): Int { - var teamStartingRating = 0 - players.forEach { teamStartingRating += it.currentRating } - return teamStartingRating / players.size +fun calculateTeamRating(players: Collection): Int { + return players.sumBy { it.currentRating }.div(players.size) } -fun calculateTeamAverageGamesPlayed(players: List): Int { - var teamGamesPlayed = 0 - players.forEach { teamGamesPlayed += it.gamesPlayed } - return teamGamesPlayed / players.size +fun calculateTeamGamesPlayed(players: Collection): Int { + return players.sumBy { it.gamesPlayed }.div(players.size) } -fun buildTeam(allPlayers: Map, teamIds: List): List { - val teamPlayers = ArrayList() - teamIds.forEach { teamPlayers += allPlayers[it]!! } - return teamPlayers +fun buildTeam(allPlayers: Map, teamIds: List): Collection { + if(teamIds.any { !allPlayers.containsKey(it) }) { + throw RuntimeException("playerId list contains ids which no player has") + } + return allPlayers.filterKeys { teamIds.contains(it) }.values } fun addNewPlayers(existingPlayers: Map, game: Game, startingRating: Int): Map { @@ -24,11 +21,10 @@ fun addNewPlayers(existingPlayers: Map, game: Game, startingRati val allPlayerIds = game.team1PlayerIds + game.team2PlayerIds allPlayerIds.forEach { playerId -> if (!newPlayerMap.containsKey(playerId)) { - newPlayerMap += Pair(playerId, Player(id = playerId, - gamesPlayed = 0, - wins = 0, - losses = 0, - currentRating = startingRating)) + newPlayerMap += Pair(playerId, Player( + id = playerId, + currentRating = startingRating + )) } } return newPlayerMap diff --git a/src/main/kotlin/com/github/wakingrufus/elo/RatingHistoryEvents.kt b/src/main/kotlin/com/github/wakingrufus/elo/RatingHistoryEvents.kt index d95aa49..886107f 100644 --- a/src/main/kotlin/com/github/wakingrufus/elo/RatingHistoryEvents.kt +++ b/src/main/kotlin/com/github/wakingrufus/elo/RatingHistoryEvents.kt @@ -8,12 +8,10 @@ private val logger = KotlinLogging.logger {} fun calculateChanges(league: League, players: Map, game: Game): List { val team1Players = buildTeam(allPlayers = players, teamIds = game.team1PlayerIds) - val team1StartingRating = calculateTeamStartingRating(team1Players) - val team1AvgGamesPlayed = calculateTeamAverageGamesPlayed(team1Players) + val team1StartingRating = calculateTeamRating(team1Players) val team2Players = buildTeam(allPlayers = players, teamIds = game.team2PlayerIds) - val team2StartingRating = calculateTeamStartingRating(team2Players) - val team2AvgGamesPlayed = calculateTeamAverageGamesPlayed(team2Players) + val team2StartingRating = calculateTeamRating(team2Players) val team1ExpectedScoreRatio = calculateExpectedScore(team1StartingRating, team2StartingRating, league.xi) val team2ExpectedScoreRatio = calculateExpectedScore(team2StartingRating, team1StartingRating, league.xi) @@ -28,44 +26,47 @@ fun calculateChanges(league: League, players: Map, game: Game): logger.debug("team1ActualScore: " + team1ActualScore.toPlainString()) logger.debug("team2ActualScore: " + team2ActualScore.toPlainString()) - var changeList: List = ArrayList() - for (player in team1Players) { - logger.debug("applying changes to player: " + player.toString()) - val kFactor = calculateKfactor(league.kFactorBase, league.trialPeriod, - league.trialKFactorMultiplier, player.gamesPlayed, team2AvgGamesPlayed) - logger.debug("using kfactor: " + kFactor) - val adjustment = calculateAdjustment(kFactor, team1ActualScore, team1ExpectedScoreRatio) - logger.debug("adjustment: " + adjustment) - val newHistory = RatingHistoryItem( + return team1Players.map { player -> + RatingHistoryItem( gameId = game.id, playerId = player.id, - ratingAdjustment = adjustment, + ratingAdjustment = calculateAdjustment( + kFactor = calculateKfactor( + kfactorBase = league.kFactorBase, + trialPeriod = league.trialPeriod, + trialMultiplier = league.trialKFactorMultiplier, + playerGamesPlayed = player.gamesPlayed, + otherPlayerGamesPlayed = calculateTeamGamesPlayed(team2Players) + ), + score = team1ActualScore, + expectedScore = team1ExpectedScoreRatio + ), startingRating = player.currentRating, win = game.team1Score > game.team2Score ) - changeList += newHistory - } - - for (player in team2Players) { - logger.debug("applying changes to player: " + player.toString()) - val kFactor = calculateKfactor(league.kFactorBase, league.trialPeriod, - league.trialKFactorMultiplier, player.gamesPlayed, team1AvgGamesPlayed) - logger.debug("using kfactor: " + kFactor) - val adjustment = calculateAdjustment(kFactor, team2ActualScore, team2ExpectedScoreRatio) - logger.debug("adjustment: " + adjustment) - val newHistory = RatingHistoryItem( + }.plus(team2Players.map { player -> + RatingHistoryItem( gameId = game.id, playerId = player.id, - ratingAdjustment = adjustment, + ratingAdjustment = calculateAdjustment( + kFactor = calculateKfactor( + kfactorBase = league.kFactorBase, + trialPeriod = league.trialPeriod, + trialMultiplier = league.trialKFactorMultiplier, + playerGamesPlayed = player.gamesPlayed, + otherPlayerGamesPlayed = calculateTeamGamesPlayed(team1Players) + ), + score = team2ActualScore, + expectedScore = team2ExpectedScoreRatio + ), startingRating = player.currentRating, win = game.team2Score > game.team1Score ) - changeList += newHistory - } - return changeList + }).toList() } -fun applyChanges(players: Map, changes: List): Map { +fun applyChanges(players: Map, changes: List) + : Map { var newMap = players changes.forEach { newMap += Pair(it.playerId, updatePlayer( diff --git a/src/test/kotlin/com/github/wakingrufus/elo/BigDecimalKtTest.kt b/src/test/kotlin/com/github/wakingrufus/elo/BigDecimalKtTest.kt new file mode 100644 index 0000000..bef921f --- /dev/null +++ b/src/test/kotlin/com/github/wakingrufus/elo/BigDecimalKtTest.kt @@ -0,0 +1,37 @@ +package com.github.wakingrufus.elo + +import org.junit.Test +import java.math.BigDecimal +import kotlin.test.assertTrue + +class BigDecimalKtTest { + @Test + fun pow() { + 2 `to the` 3 equals 8 + 10 `to the` 3 equals 1000 + 10 `to the` 1.5 equals BigDecimal("31.622776601683795227870632515987381339073181152343750") + 5 `to the` 0 equals 1 + 5 `to the` 1 equals 5 + 10 `to the` -2 equals BigDecimal("0.01") + 10 `to the` -1 equals BigDecimal("0.1") + } + + infix fun Int.`to the`(exp: Int): BigDecimal { + return this.toBigDecimal().pow(exp.toBigDecimal()) + } + + infix fun Int.`to the`(exp: Double): BigDecimal { + return this.toBigDecimal().pow(exp.toBigDecimal()) + } + + infix fun Number.equals(result: Int): Unit { + assertTrue(BigDecimal(this.toString()).compareTo(result.toBigDecimal()) == 0, + "expected ${this} but was $result") + } + + infix fun Number.equals(expected: BigDecimal): Unit { + assertTrue(BigDecimal(this.toString()).compareTo(expected) == 0, + "expected $expected but was $this") + } + +} \ No newline at end of file diff --git a/src/test/kotlin/com/github/wakingrufus/elo/EloLeagueKtTest.kt b/src/test/kotlin/com/github/wakingrufus/elo/EloLeagueKtTest.kt index 4be6d6c..d063319 100644 --- a/src/test/kotlin/com/github/wakingrufus/elo/EloLeagueKtTest.kt +++ b/src/test/kotlin/com/github/wakingrufus/elo/EloLeagueKtTest.kt @@ -12,7 +12,7 @@ class EloLeagueKtTest { // data val player1Id = UUID.randomUUID().toString() val player2Id = UUID.randomUUID().toString() - val league = League(teamSize = 1, kFactorBase = 32, trialKFactorMultiplier = 1) + val league = League(kFactorBase = 32, trialKFactorMultiplier = 1) val game = Game( id = UUID.randomUUID().toString(), team1Score = 10, @@ -48,7 +48,7 @@ class EloLeagueKtTest { val player2Id = UUID.randomUUID().toString() val player1 = Player(id = player1Id, gamesPlayed = 20, currentRating = 1200, wins = 5, losses = 15) val player2 = Player(id = player2Id, gamesPlayed = 15, currentRating = 1600, losses = 0, wins = 15) - val league = League(teamSize = 1, kFactorBase = 32, trialKFactorMultiplier = 1) + val league = League(kFactorBase = 32, trialKFactorMultiplier = 1) val game = Game( id = UUID.randomUUID().toString(), team1Score = 9, diff --git a/src/test/kotlin/com/github/wakingrufus/elo/PlayerUtilKtTest.kt b/src/test/kotlin/com/github/wakingrufus/elo/PlayerUtilKtTest.kt new file mode 100644 index 0000000..774f0a5 --- /dev/null +++ b/src/test/kotlin/com/github/wakingrufus/elo/PlayerUtilKtTest.kt @@ -0,0 +1,54 @@ +package com.github.wakingrufus.elo + +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Test + +class PlayerUtilKtTest { + @Test + fun `test calculateTeamRating`() { + assertEquals("should return average of ratings", + 1500, + calculateTeamRating(listOf( + Player(id = "1", currentRating = 1400), + Player(id = "2", currentRating = 1600) + ))) + } + + @Test + fun `test calculateTeamAverageGamesPlayed`() { + assertEquals("returns average", + 3, + calculateTeamGamesPlayed(listOf( + Player(id = "1", currentRating = 1400, gamesPlayed = 2), + Player(id = "2", currentRating = 1600, gamesPlayed = 4) + ))) + assertEquals("returns average truncated", + 3, + calculateTeamGamesPlayed(listOf( + Player(id = "1", currentRating = 1400, gamesPlayed = 3), + Player(id = "2", currentRating = 1600, gamesPlayed = 4) + ))) + } + + @Test + fun `test buildTeam`() { + val p1 = Player(id = "1", currentRating = 1550) + val p2 = Player(id = "2", currentRating = 1000) + assertArrayEquals(listOf(p1).toTypedArray(), + buildTeam( + mapOf("1" to p1, "2" to p2), + listOf("1") + ).toTypedArray()) + } + + @Test(expected = RuntimeException::class) + fun `test buildTeam with missing id`() { + val p1 = Player(id = "1", currentRating = 1550) + val p2 = Player(id = "2", currentRating = 1000) + buildTeam( + mapOf("1" to p1, "2" to p2), + listOf("1", "3") + ) + } +} \ No newline at end of file