From 9c65f0b45a8cfecc57f11db366194d6cfe97c285 Mon Sep 17 00:00:00 2001 From: JamBox <8935453+JamesVanBoxtel@users.noreply.github.com> Date: Thu, 2 Jan 2025 15:37:11 -0800 Subject: [PATCH 1/5] Implement Glicko Model and Tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit implements the Glicko 2 rating system model designed by Mark Glickman. Taken from Wikipedia “The main difference of Glicko is measurement of the "ratings reliability", called RD, for ratings deviation. The Reliability Deviation (RD) measures the accuracy of a player's rating, where the RD is equal to one standard deviation. For example, a player with a rating of 1500 and an RD of 50 has a real strength between 1400 and 1600 (two standard deviations from 1500) with 95% confidence. Twice (exact: 1.96) the RD is added and subtracted from their rating to calculate this range. After a game, the amount the rating changes depends on the RD: the change is smaller when the player's RD is low (since their rating is already considered accurate), and also when their opponent's RD is high (since the opponent's true rating is not well known, so little information is being gained). The RD itself decreases after playing a game, but it will increase slowly over time of inactivity.” I have tweaked the constants and ran tests so that the rating deviation is configured to be provisional when greater than 125. This corresponds to playing ranked a lot and then not playing ranked for about 3 months. The client should be setup to mark the rating as provisional when above that to help the player know its not confident. Some interfaces put a question mark next the rating. We should probably set a second threshold to not show the rating at all, maybe like 200 or 250.
 Players also should only gain about 2 or so rating points when playing similar players with low RD. Another benefit of this system is newcomers can get their rating more easily and deterministically, and established players won’t be affected so much by playing newcomers. --- common/data/Glicko.lua | 336 ++++++++++++++++++++ common/data/PlayerRating.lua | 119 +++++++ common/tests/data/GlickoTests.lua | 108 +++++++ common/tests/data/PlayerRatingTests.lua | 405 ++++++++++++++++++++++++ testLauncher.lua | 2 + 5 files changed, 970 insertions(+) create mode 100644 common/data/Glicko.lua create mode 100644 common/data/PlayerRating.lua create mode 100644 common/tests/data/GlickoTests.lua create mode 100644 common/tests/data/PlayerRatingTests.lua diff --git a/common/data/Glicko.lua b/common/data/Glicko.lua new file mode 100644 index 00000000..9199d7bf --- /dev/null +++ b/common/data/Glicko.lua @@ -0,0 +1,336 @@ +-- Credit to "SpaceCube" for implementing the lua implementations +-- https://devforum.roblox.com/t/a-lua-implementation-of-the-glicko-2-rating-algorithm-for-skill-based-matchmaking/1442673 +-- 1 small bug fix is included: to1:() returned the wrong version + +local util = require("common.lib.util") + +--http://www.glicko.net/glicko/glicko2.pdf +local c = 173.7178 -- Used for conversion between Glicko1 and 2, not to be confused with c from Glicko1 +local epsilon = 1e-6 -- Convergence + +-- Shortcuts for commonly used math functions +local exp = math.exp +local sqrt = math.sqrt +local log = math.log + +-- Glicko2 +local Glicko2 = { + Tau = 0.5, -- Slider for volatility Smaller values prevent the volatility measures from changing by large + -- amounts, which in turn prevent enormous changes in ratings based on very improbable results. + InitialVolatility = 0.06 +}; Glicko2.__index = Glicko2 + +-- Creates a Glicko rating with a specified version +function Glicko2.gv(Rating, RatingDeviation, Volatility, Version) + local self = { + Rating = Rating, + RD = RatingDeviation, + Vol = Volatility or Glicko2.InitialVolatility, + Version = Version or 2, + } + + return setmetatable(self, Glicko2) +end + +-- Creates a Glicko1 rating +function Glicko2.g1(Rating, RatingDeviation, Volatility) + return Glicko2.gv( + Rating or 1500, + RatingDeviation or 350, + Volatility, + 1 + ) +end + +-- Creates a Glicko2 rating +function Glicko2.g2(Rating, RatingDeviation, Volatility) + return Glicko2.gv( + Rating or 0, + RatingDeviation or (350/c), + Volatility, + 2 + ) +end + +function Glicko2:copy() + return Glicko2.gv(self.Rating, self.RD, self.Vol, self.Version) +end + +-- Scales glicko rating to Glicko2 +function Glicko2:to2() + if self.Version == 2 then + return self:copy() + end + + local g2 = Glicko2.g2((self.Rating - 1500)/c, self.RD/c, self.Vol) + + if self.Score then + g2.Score = self.Score + end + + return g2 +end + +-- Scales glicko rating to Glicko1 +function Glicko2:to1() + if self.Version == 1 then + return self:copy() + end + + local g1 = Glicko2.g1(self.Rating*c + 1500, self.RD*c, self.Vol) + + if self.Score then + g1.Score = self.Score + end + + return g1 +end + +function Glicko2.serialize(gv) + return { + gv.Rating, + gv.RD, + gv.Vol, + gv.Score + } +end + +function Glicko2.deserialize(gv_s, version) + local constructor = nil + + -- Finds glicko constructor for specified version + if version == 1 then + constructor = Glicko2.g1 + elseif version == 2 then + constructor = Glicko2.g2 + else + error("Version must be specified for deserialization", 2) + end + + local gv = constructor(gv_s[1], gv_s[2], gv_s[3]) + + -- Inserts a score if there is one + if gv_s[4] then + gv = gv:score(gv_s[4]) + end + + return gv +end + +function Glicko2.updatedRatings(player1, player2, matchOutcomes) + + local player1Results = {} + local player2Results = {} + for i = 1, #matchOutcomes do + local matchOutcome = matchOutcomes[i] + player1Results[#player1Results+1] = player2:score(matchOutcome) + if matchOutcome == 0 then + matchOutcome = 1 + elseif matchOutcome == 1 then + matchOutcome = 0 + end + player2Results[#player2Results+1] = player1:score(matchOutcome) + end + + local updatedPlayer1 = player1:update(player1Results) + local updatedPlayer2 = player2:update(player2Results) + + return updatedPlayer1, updatedPlayer2 +end + +-- Attaches a score to an opponent +function Glicko2:score(score) + local new_g2 = self:copy() + + --lost: 0, win: 1, tie: 0.5 + new_g2.Score = score or 0 + + return new_g2 +end + +-- Function g as described in step 3 +local function g(RD) + return 1/sqrt(1 + 3*RD^2/math.pi^2) +end + +-- Function E as described in step 3 +local function E(rating, opRating, opRD) + return 1/(1 + exp(-g(opRD)*(rating - opRating))) +end + +-- Constructor for function f described in step 5 +local function makebigf(g2, v, delta) + local a = log(g2.Vol^2) + + return function(x) + local numer = exp(x)*(delta^2 - g2.RD^2 - v - exp(x)) --numerator + local denom = 2*(g2.RD^2 + v + exp(x))^2 --denominator + local endTerm = (x - a)/(Glicko2.Tau^2) --final term + + return numer/denom - endTerm + end +end + +-- Updates a Glicko rating using the last set of matches +function Glicko2:update(matches) + local g2 = self + local originalVersion = g2.Version + + -- convert ratings to glicko2 + if originalVersion == 1 then + g2 = g2:to2() + end + + local convertedMatches = {} + for i, match in ipairs(matches) do + if match.Version == 1 then + convertedMatches[i] = match:to2() + end + end + + -- step 3: compute v + local v = 0 + + for j, match in ipairs(convertedMatches) do + local EValue = E(g2.Rating, match.Rating, match.RD) + + v = v + g(match.RD)^2*EValue*(1 - EValue) + end + + v = 1/v + + -- step 4: compute delta + local delta = 0 + + for j, match in ipairs(convertedMatches) do + local EValue = E(g2.Rating, match.Rating, match.RD) + + delta = delta + g(match.RD)*(match.Score - EValue) + end + + delta = delta*v + + -- step 5: find new volatility (iterative process) + local a = log(g2.Vol^2) + + local bigf = makebigf(g2, v, delta) + + -- step 5.2: find initial A and B values + local A = a + local B = 0 + + if delta^2 > g2.RD^2 + v then + B = log(delta^2 - g2.RD^2 - v) + else + --iterative process for solving B + local k = 1 + + while bigf(a - k*Glicko2.Tau) < 0 do + k = k + 1 + end + + B = a - k*Glicko2.Tau + end + + -- step 5.3: compute values of bigf of A and B + local fA = bigf(A) + local fB = bigf(B) + + -- step 5.4: iterates until A and B converge + while math.abs(B - A) > epsilon do + local C = A + (A - B)*fA/(fB - fA) + local fC = bigf(C) + + if fC*fB <= 0 then + A = B + fA = fB + else + fA = fA/2 + end + + B = C + fB = fC + end + + -- step 5.5: set new volatility + local newVol = g2.Vol + + if #convertedMatches > 0 then + newVol = exp(A/2) + end + + -- step 6: update the rating deviation to the new pre-rating period value + local ratingDeviation = sqrt(g2.RD^2 + newVol^2) + + -- Step 7: Update to the new rating + + local newRD = 1/sqrt(1/ratingDeviation^2 + 1/v) + local newRating = g2.Rating + + if #convertedMatches > 0 then + local accumulation = 0 + for j, match in ipairs(convertedMatches) do + local EValue = E(g2.Rating, match.Rating, match.RD) + accumulation = accumulation + g(match.RD)*(match.Score - EValue) + end + + newRating = g2.Rating + newRD^2*accumulation + end + + --wrap up results + local result = Glicko2.g2(newRating, newRD, newVol) + + if originalVersion == 1 then + result = result:to1() + end + + return result +end + +function Glicko2:deviation(deviations) + deviations = deviations or 2 + local radius = self.RD*deviations + + return self.Rating - radius, self.Rating + radius +end + +function Glicko2:expectedOutcome(otherGlicko) + local g2 = self + local otherGlicko2 = otherGlicko + + -- convert ratings to glicko2 + if g2.Version == 1 then + g2 = g2:to2() + end + if otherGlicko2.Version == 1 then + otherGlicko2 = otherGlicko2:to2() + end + + local function A(glicko1, glicko2) + return g(sqrt(glicko1.RD^2+glicko2.RD^2)) * (glicko2.Rating-glicko1.Rating) + end + + local function myFunc(glicko1, glicko2) + return 1/(1 + exp(-A(glicko1, glicko2))) + end + + local result = myFunc(otherGlicko2, g2) + return result +end + +function Glicko2:range(padding) + padding = padding or 0 + local small, big = self:deviation() + + return small - padding, big + padding +end + +function Glicko2:percent(confidence) + confidence = util.bound(0, confidence, 1) + assert(confidence < 1, "Percentage cannot be equal or greater than 1") + + --This is a simple inverse erf approximation, has accuracy of +- 0.02 + return self:deviation(.5877*math.log((1 + confidence)/(1 - confidence))) +end + +return Glicko2 diff --git a/common/data/PlayerRating.lua b/common/data/PlayerRating.lua new file mode 100644 index 00000000..9149a1bb --- /dev/null +++ b/common/data/PlayerRating.lua @@ -0,0 +1,119 @@ +local Glicko2 = require("common.data.Glicko") +local class = require("common.lib.class") + +-- Represents the rating for a player +PlayerRating = + class( + function(self, rating, ratingDeviation, maxRatingDeviation, volatility, maxVolatility) + rating = rating or self.STARTING_RATING + ratingDeviation = ratingDeviation or self.STARTING_RATING_DEVIATION + self.maxRatingDeviation = maxRatingDeviation or PlayerRating.MAX_RATING_DEVIATION + volatility = volatility or self.STARTING_VOLATILITY + self.maxVolatility = maxVolatility or PlayerRating.MAX_VOLATILITY + self.glicko = Glicko2.g1(rating, ratingDeviation, volatility) + end +) + +PlayerRating.RATING_PERIOD_IN_SECONDS = 60 * 60 * 16 +PlayerRating.ALLOWABLE_RATING_SPREAD = 400 + +PlayerRating.STARTING_RATING = 1500 + +PlayerRating.STARTING_RATING_DEVIATION = 250 +PlayerRating.MAX_RATING_DEVIATION = 350 +PlayerRating.PROVISIONAL_RATING_DEVIATION = 125 + +PlayerRating.STARTING_VOLATILITY = 0.06 +PlayerRating.MAX_VOLATILITY = PlayerRating.STARTING_VOLATILITY + +-- Returns the rating period number for the given timestamp +function PlayerRating.ratingPeriodForTimeStamp(timestamp) + local ratingPeriod = math.floor(timestamp / (PlayerRating.RATING_PERIOD_IN_SECONDS)) + return ratingPeriod +end + +function PlayerRating:copy() + local result = deepcpy(self) + return result +end + +function PlayerRating:getRating() + return self.glicko.Rating +end + +-- Returns a percentage value (0-1) of how likely the rating thinks the player is to win +function PlayerRating:expectedOutcome(opponent) + return self.glicko:expectedOutcome(opponent.glicko) +end + +-- Returns if the player is still "provisional" +-- Provisional is only used to change how the UI looks to help the player know this rating is not accurate yet. +function PlayerRating:isProvisional() + return self.glicko.RD >= PlayerRating.PROVISIONAL_RATING_DEVIATION +end + +-- Returns an array of result objects representing the players wins against the given player +-- Really only meant for testing. +function PlayerRating:createSetResults(opponent, player1WinCount, gameCount) + + assert(gameCount >= player1WinCount) + + local matchSet = {} + for i = 1, player1WinCount, 1 do + matchSet[#matchSet+1] = 1 + end + for i = 1, gameCount - player1WinCount, 1 do + matchSet[#matchSet+1] = 0 + end + + local player1Results = {} + for j = 1, #matchSet do -- play through games + local matchOutcome = matchSet[j] + local gameResult = self:createGameResult(opponent, matchOutcome) + if gameResult then + player1Results[#player1Results+1] = gameResult + end + end + + return player1Results +end + +-- Helper function to create one game result with the given outcome if the players are allowed to rank. +function PlayerRating:createGameResult(opponent, matchOutcome) + local result = nil + + if math.abs(self:getRating() - opponent:getRating()) <= PlayerRating.ALLOWABLE_RATING_SPREAD then + result = opponent.glicko:score(matchOutcome) + end + + return result +end + +function PlayerRating.invertedGameResult(gameResult) + if gameResult == 0 then + return 1 + end + if gameResult == 1 then + return 0 + end + -- Ties stay 0.5 + return gameResult +end + +-- Runs one "rating period" with the given results for the player. +-- To get the accurate rating of a player, this must be run on every rating period since the last time they were updated. +function PlayerRating:newRatingForRatingPeriodWithResults(gameResults) + local updatedGlicko = self.glicko:update(gameResults) + if updatedGlicko.RD > self.maxRatingDeviation then + updatedGlicko.RD = self.maxRatingDeviation + end + if updatedGlicko.Vol > self.maxVolatility then + updatedGlicko.Vol = self.maxVolatility + end + local updatedPlayer = self:copy() + updatedPlayer.glicko = updatedGlicko + return updatedPlayer +end + + +return PlayerRating diff --git a/common/tests/data/GlickoTests.lua b/common/tests/data/GlickoTests.lua new file mode 100644 index 00000000..a91ac924 --- /dev/null +++ b/common/tests/data/GlickoTests.lua @@ -0,0 +1,108 @@ +local Glicko2 = require("common.data.Glicko") + +local function basicTest() + local player1 = Glicko2.g1(1500, 350) + local player2 = Glicko2.g1(1500, 350) + + local updatedPlayer1, updatedPlayer2 = Glicko2.updatedRatings(player1, player2, {1}) + + assert(math.floor(updatedPlayer1.Rating) == 1662) + assert(math.floor(updatedPlayer2.Rating) == 1337) + + assert(player1.RD > updatedPlayer1.RD) + assert(player2.RD > updatedPlayer2.RD) +end + +basicTest() + +local function expectedOutcome() + local player1 = Glicko2.g1(1500, 350) + local player2 = Glicko2.g1(1500, 350) + + assert(player1:expectedOutcome(player2) == 0.5) + assert(player2:expectedOutcome(player1) == 0.5) + + local updatedPlayer1, updatedPlayer2 = Glicko2.updatedRatings(player1, player2, {1}) + + assert(math.floor(updatedPlayer1.Rating) == 1662) + assert(math.floor(updatedPlayer2.Rating) == 1337) + + assert(player1.RD > updatedPlayer1.RD) + assert(player2.RD > updatedPlayer2.RD) + + assert(math.round(updatedPlayer1:expectedOutcome(updatedPlayer2), 2) == 0.76) + assert(math.round(updatedPlayer2:expectedOutcome(updatedPlayer1), 2) == 0.24) + + local player3 = Glicko2.g1(2000, 40) + local player4 = Glicko2.g1(1500, 350) + + assert(math.round(player3:expectedOutcome(player4), 2) == 0.87) + + local player4 = Glicko2.g1(2000, 40) + local player5 = Glicko2.g1(600, 40) + + assert(math.round(player4:expectedOutcome(player5), 4) == .9996) + + local player6 = Glicko2.g1(2500, 40) + local player7 = Glicko2.g1(1500, 40) + + assert(math.round(player6:expectedOutcome(player7), 4) == .9965) +end + +expectedOutcome() + +local function establishedVersusNew() + + local player1 = Glicko2.g1(1500, 40) + local player2 = Glicko2.g1(1500, 350) + + local updatedPlayer1, updatedPlayer2 = Glicko2.updatedRatings(player1, player2, {1, 1, 1, 1, 1, 1, 1, 1, 1, 0}) + + assert(math.floor(updatedPlayer1.Rating) == 1524) + assert(math.floor(updatedPlayer2.Rating) == 1245) + + assert(math.floor(updatedPlayer1.RD) == 40) + assert(math.floor(updatedPlayer2.RD) == 105) +end + +establishedVersusNew() + +local function orderDoesntMatter() + + local player1 = Glicko2.g1(1500, 350) + local player2 = Glicko2.g1(1500, 350) + + local player1Copy = player1:copy() + local player2Copy = player2:copy() + + local updatedPlayer1, updatedPlayer2 = Glicko2.updatedRatings(player1, player2, {1, 1, 1, 0}) + + local updatedPlayer1Copy, updatedPlayer2Copy = Glicko2.updatedRatings(player1Copy, player2Copy, {0, 1, 1, 1}) + + assert(updatedPlayer1Copy.Rating == updatedPlayer1.Rating) + assert(updatedPlayer2Copy.Rating == updatedPlayer2.Rating) +end + +orderDoesntMatter() + +local function paperExample() + + local player1 = Glicko2.g1(1500, 200) + local player2 = Glicko2.g1(1400, 30) + local player3 = Glicko2.g1(1550, 100) + local player4 = Glicko2.g1(1700, 300) + + local player1Results = {} + player1Results[#player1Results+1] = player2:score(1) + player1Results[#player1Results+1] = player3:score(0) + player1Results[#player1Results+1] = player4:score(0) + + local updatedPlayer1 = player1:update(player1Results) + + assert(math.round(updatedPlayer1.Rating, 2) == 1464.05) + assert(math.round(updatedPlayer1.RD, 2) == 151.52) + assert(math.round(updatedPlayer1.Vol, 2) == 0.06) +end + +paperExample() + diff --git a/common/tests/data/PlayerRatingTests.lua b/common/tests/data/PlayerRatingTests.lua new file mode 100644 index 00000000..45599c7b --- /dev/null +++ b/common/tests/data/PlayerRatingTests.lua @@ -0,0 +1,405 @@ +local PlayerRating = require("common.data.PlayerRating") +local simpleCSV = require("server.simplecsv") +local tableUtils = require("common.lib.tableUtils") + +-- If starting RD is too high, or too many matches happen in one rating period, massive swings can happen. +-- This test is to explore that and come up with sane values. +local function testWeirdNumberStability() + + local player1 = PlayerRating(1273, 20) + local player2 = PlayerRating(1500, 20) + + local wins = 12 + local totalGames = 25 + + local updatedPlayer1 = player1:newRatingForRatingPeriodWithResults(player1:createSetResults(player2, wins, totalGames)) + local updatedPlayer2 = player2:newRatingForRatingPeriodWithResults(player1:createSetResults(player1, totalGames-wins, totalGames)) + + assert(updatedPlayer1:getRating() > 1073) + assert(updatedPlayer2:getRating() < 1500) + assert(updatedPlayer2:getRating() > 1338) +end + +testWeirdNumberStability() + +local function testRatingPeriodsForOccasionalPlayers() + local players = {} + for _ = 1, 3 do + players[#players+1] = PlayerRating() + end + + local previousPlayers = nil + for i = 1, 100, 1 do + local playerResults = {} + for _ = 1, 3 do + playerResults[#playerResults+1] = {} + end + local gameCount = 10 + local winPercentage = .6 + local winCount = math.ceil(winPercentage*gameCount) + tableUtils.appendToList(playerResults[1], players[1]:createSetResults(players[2], winCount, gameCount)) + tableUtils.appendToList(playerResults[2], players[2]:createSetResults(players[1], gameCount-winCount, gameCount)) + + gameCount = 5 + winPercentage = .8 + winCount = math.ceil(winPercentage*gameCount) + tableUtils.appendToList(playerResults[1], players[1]:createSetResults(players[3], winCount, gameCount)) + tableUtils.appendToList(playerResults[3], players[3]:createSetResults(players[1], gameCount-winCount, gameCount)) + + gameCount = 5 + winPercentage = .6 + winCount = math.ceil(winPercentage*gameCount) + tableUtils.appendToList(playerResults[2], players[2]:createSetResults(players[3], winCount, gameCount)) + tableUtils.appendToList(playerResults[3], players[3]:createSetResults(players[2], gameCount-winCount, gameCount)) + + previousPlayers = {} + for k = 1, 3 do + previousPlayers[#previousPlayers+1] = players[k]:copy() + end + for k = 1, 3 do + players[k] = players[k]:newRatingForRatingPeriodWithResults(playerResults[k]) + end + end + + assert(players[1]:getRating() > players[2]:getRating()) + assert(players[1]:getRating() > players[3]:getRating()) + assert(players[2]:getRating() > players[3]:getRating()) + + assert(players[1].glicko.RD < players[3].glicko.RD) + assert(players[2].glicko.RD < players[3].glicko.RD) + + assert(players[1].glicko.RD < 60) + assert(players[2].glicko.RD < 60) + assert(players[3].glicko.RD < 60) + + for k = 1, 3 do + -- rating and deviation should stabilize over time if players perform the same + assert(math.abs(previousPlayers[k]:getRating() - players[k]:getRating()) < 1) + assert(math.abs(previousPlayers[k].glicko.RD - players[k].glicko.RD) < 1) + assert(previousPlayers[k]:isProvisional() == false) + end +end + +testRatingPeriodsForOccasionalPlayers() + +local function testRatingPeriodsForObsessivePlayers() + local players = {} + for _ = 1, 3 do + players[#players+1] = PlayerRating() + end + + local previousPlayers = nil + for i = 1, 100, 1 do + local playerResults = {} + for _ = 1, 3 do + playerResults[#playerResults+1] = {} + end + local gameCount = 100 + local winPercentage = .6 + local winCount = math.ceil(winPercentage*gameCount) + tableUtils.appendToList(playerResults[1], players[1]:createSetResults(players[2], winCount, gameCount)) + tableUtils.appendToList(playerResults[2], players[2]:createSetResults(players[1], gameCount-winCount, gameCount)) + + gameCount = 80 + winPercentage = .8 + winCount = math.ceil(winPercentage*gameCount) + tableUtils.appendToList(playerResults[1], players[1]:createSetResults(players[3], winCount, gameCount)) + tableUtils.appendToList(playerResults[3], players[3]:createSetResults(players[1], gameCount-winCount, gameCount)) + + gameCount = 60 + winPercentage = .6 + winCount = math.ceil(winPercentage*gameCount) + tableUtils.appendToList(playerResults[2], players[2]:createSetResults(players[3], winCount, gameCount)) + tableUtils.appendToList(playerResults[3], players[3]:createSetResults(players[2], gameCount-winCount, gameCount)) + + previousPlayers = {} + for k = 1, 3 do + previousPlayers[#previousPlayers+1] = players[k]:copy() + end + for k = 1, 3 do + players[k] = players[k]:newRatingForRatingPeriodWithResults(playerResults[k]) + end + end + + assert(players[1]:getRating() > players[2]:getRating()) + assert(players[1]:getRating() > players[3]:getRating()) + assert(players[2]:getRating() > players[3]:getRating()) + + assert(players[1].glicko.RD < players[3].glicko.RD) + assert(players[2].glicko.RD < players[3].glicko.RD) + + assert(players[1].glicko.RD < 20) + assert(players[2].glicko.RD < 20) + assert(players[3].glicko.RD < 20) + + for k = 1, 3 do + -- rating and deviation should stabilize over time if players perform the same + assert(math.abs(previousPlayers[k]:getRating() - players[k]:getRating()) < 1) + assert(math.abs(previousPlayers[k].glicko.RD - players[k].glicko.RD) < 1) + assert(previousPlayers[k]:isProvisional() == false) + end +end + +testRatingPeriodsForObsessivePlayers() + +-- When a stable player doesn't play for a long time, we should lose some confidence in their rating, but not all. +local function testMaxRD() + local playerRating = PlayerRating(2000, 30) + + local threeMonthsInSeconds = 60 * 60 * 24 * 31 * 3 + local threeMonthsOfRatingPeriod = math.ceil(threeMonthsInSeconds / PlayerRating.RATING_PERIOD_IN_SECONDS) + for i = 1, threeMonthsOfRatingPeriod, 1 do + playerRating = playerRating:newRatingForRatingPeriodWithResults({}) + end + + assert(playerRating.glicko.RD >= 120) + + local nineMoreMonths = 60 * 60 * 24 * 31 * 9 + local oneYearOfRatingPeriod = math.ceil(nineMoreMonths / PlayerRating.RATING_PERIOD_IN_SECONDS) + for i = 1, oneYearOfRatingPeriod, 1 do + playerRating = playerRating:newRatingForRatingPeriodWithResults({}) + end + + assert(playerRating.glicko.RD >= 245) +end + +testMaxRD() + +local function testFarming() + local players = {} + for _ = 1, 2 do + players[#players+1] = PlayerRating() + end + + -- Player 1 and 2 play normal sets to get a standard + for i = 1, 100, 1 do + local playerResults = {} + for _ = 1, 2 do + playerResults[#playerResults+1] = {} + end + + tableUtils.appendToList(playerResults[1], players[1]:createSetResults(players[2], 11, 20)) + tableUtils.appendToList(playerResults[2], players[2]:createSetResults(players[1], 9, 20)) + + for k = 1, 2 do + players[k] = players[k]:newRatingForRatingPeriodWithResults(playerResults[k]) + end + end + + -- Farm newcomers to see how much rating you can gain + for i = 1, 100, 1 do + local playerResults = {} + for _ = 1, 1 do + playerResults[#playerResults+1] = {} + end + + local newbiePlayer = PlayerRating() + tableUtils.appendToList(playerResults[1], players[1]:createSetResults(newbiePlayer, 10, 10)) + + for k = 1, 1 do + players[k] = players[k]:newRatingForRatingPeriodWithResults(playerResults[k]) + end + end + + assert(players[1]:getRating() > PlayerRating.STARTING_RATING + PlayerRating.ALLOWABLE_RATING_SPREAD) -- Ranked high enough we can't play default players anymore + assert(players[1]:getRating() < 2000) -- Thus we couldn't farm really high + +end + +testFarming() + +local function testSingleGameNotTooBigRatingChange() + local players = {} + for _ = 1, 2 do + players[#players+1] = PlayerRating() + end + + -- Player 1 and 2 play normal sets to get a standard + for i = 1, 100, 1 do + local playerResults = {} + for _ = 1, 2 do + playerResults[#playerResults+1] = {} + end + + tableUtils.appendToList(playerResults[1], players[1]:createSetResults(players[2], 11, 20)) + tableUtils.appendToList(playerResults[2], players[2]:createSetResults(players[1], 9, 20)) + + for k = 1, 2 do + players[k] = players[k]:newRatingForRatingPeriodWithResults(playerResults[k]) + end + end + + local firstRating = players[1]:getRating() + assert(firstRating > 1515 and firstRating < 1525) + + local playerResults = {} + tableUtils.appendToList(playerResults, players[1]:createSetResults(players[2], 1, 1)) + players[1] = players[1]:newRatingForRatingPeriodWithResults(playerResults) + + local secondRating = players[1]:getRating() + local ratingDifference = secondRating - firstRating + assert(ratingDifference > 2 and ratingDifference < 4) -- Rating shouldn't change too much from one game +end + +testSingleGameNotTooBigRatingChange() + +local usedNames = {} +local publicIDMap = {} -- mapping of privateID to publicID +local function cleanNameForName(name, privateID) + name = name or "Player" + privateID = tostring(privateID) + if publicIDMap[privateID] == nil then + local result = name + while usedNames[result] ~= nil do + result = name .. math.random(1000000, 9999999) + end + usedNames[result] = true + publicIDMap[privateID] = result + end + + return publicIDMap[privateID] +end + +local function runRatingPeriods(firstRatingPeriod, lastRatingPeriod, players, glickoResultsTable) + -- Run each rating period (the later ones will just increase RD) + for i = firstRatingPeriod, lastRatingPeriod, 1 do + for playerID, playerTable in pairs(players) do + + local playerRating = playerTable.playerRating + local gameResults = playerTable.gameResults + local newPlayerRating = playerRating:newRatingForRatingPeriodWithResults(gameResults) + + playerTable.playerRating = newPlayerRating + playerTable.gameResults = {} + end + + if i == firstRatingPeriod then + for playerID, playerTable in pairs(players) do + local row = {} + row[#row+1] = i + row[#row+1] = playerID + row[#row+1] = playerTable.playerRating:getRating() + row[#row+1] = playerTable.playerRating.glicko.RD + if firstRatingPeriod % 20 == 1 then + glickoResultsTable[#glickoResultsTable+1] = row + end + end + end + end +end + +-- This test is to experiment with real world server data to verify the values work well. +-- put the players.txt, and GameResults.csv files in the root directory to run this test. +-- Make sure you don't keep this test enabled or commit those files! +local function testRealWorldData() + local players = {} + local glickoResultsTable = {} + local ratingPeriodNeedingRun = nil + local latestRatingPeriodFound = nil + local gameResults = simpleCSV.read("GameResults.csv") + assert(gameResults) + + local playersFile, err = love.filesystem.newFile("players.txt", "r") + if playersFile then + local tehJSON = playersFile:read(playersFile:getSize()) + playersFile:close() + playersFile = nil + local playerData = json.decode(tehJSON) or {} + if playerData then + for key, value in pairs(playerData) do + cleanNameForName(value, key) + end + end + end + + for row = 1, #gameResults do + local player1ID = cleanNameForName(nil, gameResults[row][1]) + local player2ID = cleanNameForName(nil, gameResults[row][2]) + local winResult = tonumber(gameResults[row][3]) + local ranked = tonumber(gameResults[row][4]) + local timestamp = tonumber(gameResults[row][5]) + local dateTable = os.date("*t", timestamp) + + assert(player1ID) + assert(player2ID) + assert(winResult) + assert(ranked) + assert(timestamp) + assert(dateTable) + + if ranked == 0 then + goto continue + end + + latestRatingPeriodFound = PlayerRating.ratingPeriodForTimeStamp(timestamp) + if ratingPeriodNeedingRun == nil then + ratingPeriodNeedingRun = latestRatingPeriodFound + end + + -- if we just passed the rating period, time to update ratings + if ratingPeriodNeedingRun ~= latestRatingPeriodFound then + assert(latestRatingPeriodFound > ratingPeriodNeedingRun) + runRatingPeriods(ratingPeriodNeedingRun, latestRatingPeriodFound-1, players, glickoResultsTable) + ratingPeriodNeedingRun = latestRatingPeriodFound + end + + local currentPlayerSets = {{player1ID, player2ID}, {player2ID, player1ID}} + for _, currentPlayers in ipairs(currentPlayerSets) do + local playerID = currentPlayers[1] + -- Create a new player if one doesn't exist yet. + if not players[playerID] then + players[playerID] = {} + players[playerID].playerRating = PlayerRating(1500, 250) + players[playerID].gameResults = {} + players[playerID].error = 0 + players[playerID].totalGames = 0 + end + end + + for index, currentPlayers in ipairs(currentPlayerSets) do + local player = players[currentPlayers[1]].playerRating + local opponent = players[currentPlayers[2]].playerRating + local gameResult = winResult + if index == 2 then + gameResult = PlayerRating.invertedGameResult(winResult) + end + local expected = player:expectedOutcome(opponent) + --if player:isProvisional() == false then + players[currentPlayers[1]].error = players[currentPlayers[1]].error + (gameResult - expected) + players[currentPlayers[1]].totalGames = players[currentPlayers[1]].totalGames + 1 + --end + local result = player:createGameResult(opponent, gameResult) + local gameResults = players[currentPlayers[1]].gameResults + gameResults[#gameResults+1] = result + end + + ::continue:: + end + + -- Handle the last rating period + assert(ratingPeriodNeedingRun == latestRatingPeriodFound) + runRatingPeriods(ratingPeriodNeedingRun, latestRatingPeriodFound, players, glickoResultsTable) + + local totalError = 0 + local totalGames = 0 + local provisionalCount = 0 + local playerCount = 0 + for playerID, playerTable in pairs(players) do + if playerTable.totalGames > 0 then + local error = math.abs(playerTable.error) + totalError = totalError + error + totalGames = totalGames + playerTable.totalGames + end + if playerTable.playerRating:isProvisional() then + provisionalCount = provisionalCount + 1 + end + playerCount = playerCount + 1 + end + local totalErrorPerGame = totalError / totalGames + + simpleCSV.write("Glicko.csv", glickoResultsTable) + -- 0.03724587514630 RATING PERIOD = 16hrs -- DEFAULT_RATING_DEVIATION = 250 = MAX_DEVIATION -- PROVISIONAL_DEVIATION = RD * 0.5 -- DEFAULT_VOLATILITY = 0.06 = MAX_VOLATILITY + -- 0.03792533617676 RATING PERIOD = 24hrs -- DEFAULT_RATING_DEVIATION = 250 = MAX_DEVIATION -- PROVISIONAL_DEVIATION = RD * 0.5 -- DEFAULT_VOLATILITY = 0.06 = MAX_VOLATILITY +end + +-- testRealWorldData() \ No newline at end of file diff --git a/testLauncher.lua b/testLauncher.lua index 0a8f173d..8cb4bcfe 100644 --- a/testLauncher.lua +++ b/testLauncher.lua @@ -40,6 +40,8 @@ local tests = { "client.tests.TcpClientTests", "client.tests.ThemeTests", "server.tests.ConnectionTests", + "common.tests.data.GlickoTests", + "common.tests.data.PlayerRatingTests", "common.tests.engine.GarbageQueueTests", "common.tests.engine.HealthTests", "common.tests.engine.PanelGenTests", From 1f8375141fe2cf340bd7d23d972f7df8608b9687 Mon Sep 17 00:00:00 2001 From: JamBox <8935453+JamesVanBoxtel@users.noreply.github.com> Date: Fri, 3 Jan 2025 22:25:22 -0800 Subject: [PATCH 2/5] Add Test Adding a test that currently fails, we shouldn't be able to get a negative rating. Also added game counts for debug purposes --- common/data/PlayerRating.lua | 5 ++ common/tests/data/PlayerRatingTests.lua | 71 +++++++++++++++++++++++-- 2 files changed, 73 insertions(+), 3 deletions(-) diff --git a/common/data/PlayerRating.lua b/common/data/PlayerRating.lua index 9149a1bb..b390491e 100644 --- a/common/data/PlayerRating.lua +++ b/common/data/PlayerRating.lua @@ -32,6 +32,11 @@ function PlayerRating.ratingPeriodForTimeStamp(timestamp) return ratingPeriod end +function PlayerRating.timestampForRatingPeriod(ratingPeriod) + local timestamp = ratingPeriod * PlayerRating.RATING_PERIOD_IN_SECONDS + return timestamp +end + function PlayerRating:copy() local result = deepcpy(self) return result diff --git a/common/tests/data/PlayerRatingTests.lua b/common/tests/data/PlayerRatingTests.lua index 45599c7b..c2000a00 100644 --- a/common/tests/data/PlayerRatingTests.lua +++ b/common/tests/data/PlayerRatingTests.lua @@ -1,6 +1,7 @@ local PlayerRating = require("common.data.PlayerRating") local simpleCSV = require("server.simplecsv") local tableUtils = require("common.lib.tableUtils") +local logger = require("common.lib.logger") -- If starting RD is too high, or too many matches happen in one rating period, massive swings can happen. -- This test is to explore that and come up with sane values. @@ -208,6 +209,34 @@ end testFarming() +local function testNewcomerSwing() + local players = {} + players[#players+1] = PlayerRating() + players[#players+1] = PlayerRating(1121.96, 29.65) + + -- Newcomer loses 40 times in a row... + for i = 1, 40, 1 do + local playerResults = {} + for _ = 1, 2 do + playerResults[#playerResults+1] = {} + end + + tableUtils.appendToList(playerResults[1], players[1]:createSetResults(players[2], 0, 40)) + tableUtils.appendToList(playerResults[2], players[2]:createSetResults(players[1], 40, 40)) + + for k = 1, 2 do + players[k] = players[k]:newRatingForRatingPeriodWithResults(playerResults[k]) + end + end + + assert(players[1]:getRating() > 0) -- We should never get a negative rating +end + +testNewcomerSwing() + + + + local function testSingleGameNotTooBigRatingChange() local players = {} for _ = 1, 2 do @@ -261,14 +290,23 @@ local function cleanNameForName(name, privateID) end local function runRatingPeriods(firstRatingPeriod, lastRatingPeriod, players, glickoResultsTable) + + local totalGamesPlayed = 0 + -- Run each rating period (the later ones will just increase RD) for i = firstRatingPeriod, lastRatingPeriod, 1 do for playerID, playerTable in pairs(players) do local playerRating = playerTable.playerRating local gameResults = playerTable.gameResults + totalGamesPlayed = totalGamesPlayed + #gameResults + local newPlayerRating = playerRating:newRatingForRatingPeriodWithResults(gameResults) + if playerTable.playerRating:getRating() < 0 then + local newPlayerRating2 = playerRating:newRatingForRatingPeriodWithResults(gameResults) + end + playerTable.playerRating = newPlayerRating playerTable.gameResults = {} end @@ -286,6 +324,11 @@ local function runRatingPeriods(firstRatingPeriod, lastRatingPeriod, players, gl end end end + + totalGamesPlayed = totalGamesPlayed / 2 + + local now = os.date("*t", PlayerRating.timestampForRatingPeriod(firstRatingPeriod)) + logger.info("Processing " .. firstRatingPeriod .. " to " .. lastRatingPeriod .. " on " .. string.format("%02d/%02d/%04d", now.month, now.day, now.year) .. " with " .. totalGamesPlayed .. " games") end -- This test is to experiment with real world server data to verify the values work well. @@ -296,6 +339,7 @@ local function testRealWorldData() local glickoResultsTable = {} local ratingPeriodNeedingRun = nil local latestRatingPeriodFound = nil + local gamesPlayedDays = {} local gameResults = simpleCSV.read("GameResults.csv") assert(gameResults) @@ -327,6 +371,20 @@ local function testRealWorldData() assert(timestamp) assert(dateTable) + local now = os.date("*t", timestamp) + local dateKey = string.format("%02d/%02d/%04d", now.month, now.day, now.year) + + if gamesPlayedDays[dateKey] == nil then + gamesPlayedDays[dateKey] = {"",0,0} + gamesPlayedDays[dateKey][1] = dateKey + end + + if ranked == 0 then + gamesPlayedDays[dateKey][2] = gamesPlayedDays[dateKey][2] + 1 + else + gamesPlayedDays[dateKey][3] = gamesPlayedDays[dateKey][3] + 1 + end + if ranked == 0 then goto continue end @@ -349,13 +407,14 @@ local function testRealWorldData() -- Create a new player if one doesn't exist yet. if not players[playerID] then players[playerID] = {} - players[playerID].playerRating = PlayerRating(1500, 250) + players[playerID].playerRating = PlayerRating() + assert(players[playerID].playerRating:getRating() > 0) players[playerID].gameResults = {} players[playerID].error = 0 players[playerID].totalGames = 0 end end - + for index, currentPlayers in ipairs(currentPlayerSets) do local player = players[currentPlayers[1]].playerRating local opponent = players[currentPlayers[2]].playerRating @@ -372,7 +431,7 @@ local function testRealWorldData() local gameResults = players[currentPlayers[1]].gameResults gameResults[#gameResults+1] = result end - + ::continue:: end @@ -400,6 +459,12 @@ local function testRealWorldData() simpleCSV.write("Glicko.csv", glickoResultsTable) -- 0.03724587514630 RATING PERIOD = 16hrs -- DEFAULT_RATING_DEVIATION = 250 = MAX_DEVIATION -- PROVISIONAL_DEVIATION = RD * 0.5 -- DEFAULT_VOLATILITY = 0.06 = MAX_VOLATILITY -- 0.03792533617676 RATING PERIOD = 24hrs -- DEFAULT_RATING_DEVIATION = 250 = MAX_DEVIATION -- PROVISIONAL_DEVIATION = RD * 0.5 -- DEFAULT_VOLATILITY = 0.06 = MAX_VOLATILITY + + local gamesPlayedData = {} + for dateKey, data in pairsSortedByKeys(gamesPlayedDays) do + gamesPlayedData[#gamesPlayedData+1] = data + end + simpleCSV.write("GamesPlayed.csv", gamesPlayedData) end -- testRealWorldData() \ No newline at end of file From 98606dac3f8a943e8188729d1752563b6f312f17 Mon Sep 17 00:00:00 2001 From: JamBox <8935453+JamesVanBoxtel@users.noreply.github.com> Date: Sat, 4 Jan 2025 10:24:00 -0800 Subject: [PATCH 3/5] Move to updating rating each game When a players rating isn't stable yet, doing tons of games in one rating period causes giant jumps. For our use cases it seems updating games immediately is better and running rating period changes inbetween games. --- common/data/Glicko.lua | 16 ++- common/data/PlayerRating.lua | 23 ++++- common/tests/data/GlickoTests.lua | 12 +-- common/tests/data/PlayerRatingTests.lua | 132 ++++++++++++------------ 4 files changed, 102 insertions(+), 81 deletions(-) diff --git a/common/data/Glicko.lua b/common/data/Glicko.lua index 9199d7bf..82875d44 100644 --- a/common/data/Glicko.lua +++ b/common/data/Glicko.lua @@ -117,7 +117,7 @@ function Glicko2.deserialize(gv_s, version) return gv end -function Glicko2.updatedRatings(player1, player2, matchOutcomes) +function Glicko2.updatedRatings(player1, player2, matchOutcomes, elapsedRatingPeriods) local player1Results = {} local player2Results = {} @@ -132,8 +132,8 @@ function Glicko2.updatedRatings(player1, player2, matchOutcomes) player2Results[#player2Results+1] = player1:score(matchOutcome) end - local updatedPlayer1 = player1:update(player1Results) - local updatedPlayer2 = player2:update(player2Results) + local updatedPlayer1 = player1:update(player1Results, elapsedRatingPeriods) + local updatedPlayer2 = player2:update(player2Results, elapsedRatingPeriods) return updatedPlayer1, updatedPlayer2 end @@ -172,7 +172,7 @@ local function makebigf(g2, v, delta) end -- Updates a Glicko rating using the last set of matches -function Glicko2:update(matches) +function Glicko2:update(matches, elapsedRatingPeriods) local g2 = self local originalVersion = g2.Version @@ -260,7 +260,7 @@ function Glicko2:update(matches) end -- step 6: update the rating deviation to the new pre-rating period value - local ratingDeviation = sqrt(g2.RD^2 + newVol^2) + local ratingDeviation = self:calculateNewRD(g2.RD, newVol, elapsedRatingPeriods) -- Step 7: Update to the new rating @@ -287,6 +287,12 @@ function Glicko2:update(matches) return result end +-- This is the formula defined in step 6. It is also used for players who have not competed during the rating period +function Glicko2:calculateNewRD(ratingDeviation, volatility, elapsedRatingPeriods) + local ratingDeviation = sqrt(ratingDeviation^2 + elapsedRatingPeriods * volatility^2) + return ratingDeviation +end + function Glicko2:deviation(deviations) deviations = deviations or 2 local radius = self.RD*deviations diff --git a/common/data/PlayerRating.lua b/common/data/PlayerRating.lua index b390491e..4f26feda 100644 --- a/common/data/PlayerRating.lua +++ b/common/data/PlayerRating.lua @@ -11,6 +11,7 @@ PlayerRating = volatility = volatility or self.STARTING_VOLATILITY self.maxVolatility = maxVolatility or PlayerRating.MAX_VOLATILITY self.glicko = Glicko2.g1(rating, ratingDeviation, volatility) + self.lastRatingPeriodCalculated = nil end ) @@ -28,7 +29,7 @@ PlayerRating.MAX_VOLATILITY = PlayerRating.STARTING_VOLATILITY -- Returns the rating period number for the given timestamp function PlayerRating.ratingPeriodForTimeStamp(timestamp) - local ratingPeriod = math.floor(timestamp / (PlayerRating.RATING_PERIOD_IN_SECONDS)) + local ratingPeriod = timestamp / (PlayerRating.RATING_PERIOD_IN_SECONDS) return ratingPeriod end @@ -105,10 +106,25 @@ function PlayerRating.invertedGameResult(gameResult) return gameResult end +-- Returns the rating period number for the given timestamp +function PlayerRating:newRatingUpdatedToRatingPeriod(ratingPeriod) + local updatedPlayer = self:copy() + if updatedPlayer.lastRatingPeriodCalculated == nil then + updatedPlayer.lastRatingPeriodCalculated = ratingPeriod + return updatedPlayer + elseif updatedPlayer.lastRatingPeriodCalculated >= ratingPeriod then + assert(false, "Trying to update to rating before already calculated") + end + local elapsedRatingPeriods = ratingPeriod - updatedPlayer.lastRatingPeriodCalculated + updatedPlayer = updatedPlayer:newRatingForRatingPeriodWithResults({}, elapsedRatingPeriods) + updatedPlayer.lastRatingPeriodCalculated = self.lastRatingPeriodCalculated + elapsedRatingPeriods + return updatedPlayer +end + -- Runs one "rating period" with the given results for the player. -- To get the accurate rating of a player, this must be run on every rating period since the last time they were updated. -function PlayerRating:newRatingForRatingPeriodWithResults(gameResults) - local updatedGlicko = self.glicko:update(gameResults) +function PlayerRating:newRatingForRatingPeriodWithResults(gameResults, elapsedRatingPeriods) + local updatedGlicko = self.glicko:update(gameResults, elapsedRatingPeriods) if updatedGlicko.RD > self.maxRatingDeviation then updatedGlicko.RD = self.maxRatingDeviation end @@ -120,5 +136,4 @@ function PlayerRating:newRatingForRatingPeriodWithResults(gameResults) return updatedPlayer end - return PlayerRating diff --git a/common/tests/data/GlickoTests.lua b/common/tests/data/GlickoTests.lua index a91ac924..574c5f5c 100644 --- a/common/tests/data/GlickoTests.lua +++ b/common/tests/data/GlickoTests.lua @@ -4,7 +4,7 @@ local function basicTest() local player1 = Glicko2.g1(1500, 350) local player2 = Glicko2.g1(1500, 350) - local updatedPlayer1, updatedPlayer2 = Glicko2.updatedRatings(player1, player2, {1}) + local updatedPlayer1, updatedPlayer2 = Glicko2.updatedRatings(player1, player2, {1}, 1) assert(math.floor(updatedPlayer1.Rating) == 1662) assert(math.floor(updatedPlayer2.Rating) == 1337) @@ -22,7 +22,7 @@ local function expectedOutcome() assert(player1:expectedOutcome(player2) == 0.5) assert(player2:expectedOutcome(player1) == 0.5) - local updatedPlayer1, updatedPlayer2 = Glicko2.updatedRatings(player1, player2, {1}) + local updatedPlayer1, updatedPlayer2 = Glicko2.updatedRatings(player1, player2, {1}, 1) assert(math.floor(updatedPlayer1.Rating) == 1662) assert(math.floor(updatedPlayer2.Rating) == 1337) @@ -56,7 +56,7 @@ local function establishedVersusNew() local player1 = Glicko2.g1(1500, 40) local player2 = Glicko2.g1(1500, 350) - local updatedPlayer1, updatedPlayer2 = Glicko2.updatedRatings(player1, player2, {1, 1, 1, 1, 1, 1, 1, 1, 1, 0}) + local updatedPlayer1, updatedPlayer2 = Glicko2.updatedRatings(player1, player2, {1, 1, 1, 1, 1, 1, 1, 1, 1, 0}, 1) assert(math.floor(updatedPlayer1.Rating) == 1524) assert(math.floor(updatedPlayer2.Rating) == 1245) @@ -75,9 +75,9 @@ local function orderDoesntMatter() local player1Copy = player1:copy() local player2Copy = player2:copy() - local updatedPlayer1, updatedPlayer2 = Glicko2.updatedRatings(player1, player2, {1, 1, 1, 0}) + local updatedPlayer1, updatedPlayer2 = Glicko2.updatedRatings(player1, player2, {1, 1, 1, 0}, 1) - local updatedPlayer1Copy, updatedPlayer2Copy = Glicko2.updatedRatings(player1Copy, player2Copy, {0, 1, 1, 1}) + local updatedPlayer1Copy, updatedPlayer2Copy = Glicko2.updatedRatings(player1Copy, player2Copy, {0, 1, 1, 1}, 1) assert(updatedPlayer1Copy.Rating == updatedPlayer1.Rating) assert(updatedPlayer2Copy.Rating == updatedPlayer2.Rating) @@ -97,7 +97,7 @@ local function paperExample() player1Results[#player1Results+1] = player3:score(0) player1Results[#player1Results+1] = player4:score(0) - local updatedPlayer1 = player1:update(player1Results) + local updatedPlayer1 = player1:update(player1Results, 1) assert(math.round(updatedPlayer1.Rating, 2) == 1464.05) assert(math.round(updatedPlayer1.RD, 2) == 151.52) diff --git a/common/tests/data/PlayerRatingTests.lua b/common/tests/data/PlayerRatingTests.lua index c2000a00..af4ba1dd 100644 --- a/common/tests/data/PlayerRatingTests.lua +++ b/common/tests/data/PlayerRatingTests.lua @@ -13,8 +13,8 @@ local function testWeirdNumberStability() local wins = 12 local totalGames = 25 - local updatedPlayer1 = player1:newRatingForRatingPeriodWithResults(player1:createSetResults(player2, wins, totalGames)) - local updatedPlayer2 = player2:newRatingForRatingPeriodWithResults(player1:createSetResults(player1, totalGames-wins, totalGames)) + local updatedPlayer1 = player1:newRatingForRatingPeriodWithResults(player1:createSetResults(player2, wins, totalGames), 1) + local updatedPlayer2 = player2:newRatingForRatingPeriodWithResults(player1:createSetResults(player1, totalGames-wins, totalGames), 1) assert(updatedPlayer1:getRating() > 1073) assert(updatedPlayer2:getRating() < 1500) @@ -23,6 +23,18 @@ end testWeirdNumberStability() +local function testNoMatchesInRatingPeriod() + + local player1 = PlayerRating() + + -- We shouldn't assert or blow up in here, v goes to infinity but it is okay to divide by infinity as that is 0... + local updatedPlayer1 = player1:newRatingForRatingPeriodWithResults({}, 1) + assert(updatedPlayer1:getRating() == 1500) -- rating shouldn't change + assert(updatedPlayer1.glicko.RD > 250) -- RD should go up +end + +testNoMatchesInRatingPeriod() + local function testRatingPeriodsForOccasionalPlayers() local players = {} for _ = 1, 3 do @@ -58,7 +70,7 @@ local function testRatingPeriodsForOccasionalPlayers() previousPlayers[#previousPlayers+1] = players[k]:copy() end for k = 1, 3 do - players[k] = players[k]:newRatingForRatingPeriodWithResults(playerResults[k]) + players[k] = players[k]:newRatingForRatingPeriodWithResults(playerResults[k], 1) end end @@ -118,7 +130,7 @@ local function testRatingPeriodsForObsessivePlayers() previousPlayers[#previousPlayers+1] = players[k]:copy() end for k = 1, 3 do - players[k] = players[k]:newRatingForRatingPeriodWithResults(playerResults[k]) + players[k] = players[k]:newRatingForRatingPeriodWithResults(playerResults[k], 1) end end @@ -150,7 +162,7 @@ local function testMaxRD() local threeMonthsInSeconds = 60 * 60 * 24 * 31 * 3 local threeMonthsOfRatingPeriod = math.ceil(threeMonthsInSeconds / PlayerRating.RATING_PERIOD_IN_SECONDS) for i = 1, threeMonthsOfRatingPeriod, 1 do - playerRating = playerRating:newRatingForRatingPeriodWithResults({}) + playerRating = playerRating:newRatingForRatingPeriodWithResults({}, 1) end assert(playerRating.glicko.RD >= 120) @@ -158,7 +170,7 @@ local function testMaxRD() local nineMoreMonths = 60 * 60 * 24 * 31 * 9 local oneYearOfRatingPeriod = math.ceil(nineMoreMonths / PlayerRating.RATING_PERIOD_IN_SECONDS) for i = 1, oneYearOfRatingPeriod, 1 do - playerRating = playerRating:newRatingForRatingPeriodWithResults({}) + playerRating = playerRating:newRatingForRatingPeriodWithResults({}, 1) end assert(playerRating.glicko.RD >= 245) @@ -183,7 +195,7 @@ local function testFarming() tableUtils.appendToList(playerResults[2], players[2]:createSetResults(players[1], 9, 20)) for k = 1, 2 do - players[k] = players[k]:newRatingForRatingPeriodWithResults(playerResults[k]) + players[k] = players[k]:newRatingForRatingPeriodWithResults(playerResults[k], 1) end end @@ -198,7 +210,7 @@ local function testFarming() tableUtils.appendToList(playerResults[1], players[1]:createSetResults(newbiePlayer, 10, 10)) for k = 1, 1 do - players[k] = players[k]:newRatingForRatingPeriodWithResults(playerResults[k]) + players[k] = players[k]:newRatingForRatingPeriodWithResults(playerResults[k], 1) end end @@ -215,21 +227,31 @@ local function testNewcomerSwing() players[#players+1] = PlayerRating(1121.96, 29.65) -- Newcomer loses 40 times in a row... - for i = 1, 40, 1 do + local gameCount = 1 + local setCount = 40 + local setRatingPeriodLength = 1 / 24 / 60 -- 1 mins of playing + for i = 1, setCount, 1 do local playerResults = {} for _ = 1, 2 do playerResults[#playerResults+1] = {} end - tableUtils.appendToList(playerResults[1], players[1]:createSetResults(players[2], 0, 40)) - tableUtils.appendToList(playerResults[2], players[2]:createSetResults(players[1], 40, 40)) + tableUtils.appendToList(playerResults[1], players[1]:createSetResults(players[2], 0, gameCount)) + tableUtils.appendToList(playerResults[2], players[2]:createSetResults(players[1], gameCount, gameCount)) for k = 1, 2 do - players[k] = players[k]:newRatingForRatingPeriodWithResults(playerResults[k]) + players[k] = players[k]:newRatingForRatingPeriodWithResults(playerResults[k], setRatingPeriodLength) end end - assert(players[1]:getRating() > 0) -- We should never get a negative rating + assert(players[1]:getRating() > 700) -- Newcomer shouldn't go too far below other player + assert(players[1]:getRating() < 800) -- Newcomer should go down significantly though + assert(players[1].glicko.RD > 80) -- Newcomer RD should start going down, but not too much + assert(players[1].glicko.RD < 90) -- Newcomer RD should start going down, but not too much + assert(players[2]:getRating() > 1130) -- Normal player shouldn't gain too much rating + assert(players[2]:getRating() < 1200) + assert(players[2].glicko.RD > 25) -- Normal player RD should stay similar + assert(players[2].glicko.RD < 30) end testNewcomerSwing() @@ -254,7 +276,7 @@ local function testSingleGameNotTooBigRatingChange() tableUtils.appendToList(playerResults[2], players[2]:createSetResults(players[1], 9, 20)) for k = 1, 2 do - players[k] = players[k]:newRatingForRatingPeriodWithResults(playerResults[k]) + players[k] = players[k]:newRatingForRatingPeriodWithResults(playerResults[k], 1) end end @@ -263,7 +285,7 @@ local function testSingleGameNotTooBigRatingChange() local playerResults = {} tableUtils.appendToList(playerResults, players[1]:createSetResults(players[2], 1, 1)) - players[1] = players[1]:newRatingForRatingPeriodWithResults(playerResults) + players[1] = players[1]:newRatingForRatingPeriodWithResults(playerResults, 1) local secondRating = players[1]:getRating() local ratingDifference = secondRating - firstRating @@ -289,46 +311,37 @@ local function cleanNameForName(name, privateID) return publicIDMap[privateID] end -local function runRatingPeriods(firstRatingPeriod, lastRatingPeriod, players, glickoResultsTable) +local function runRatingPeriods(latestRatingPeriodFound, lastRatingPeriodSaved, players, playerID, gameResults, glickoResultsTable) local totalGamesPlayed = 0 - -- Run each rating period (the later ones will just increase RD) - for i = firstRatingPeriod, lastRatingPeriod, 1 do - for playerID, playerTable in pairs(players) do - - local playerRating = playerTable.playerRating - local gameResults = playerTable.gameResults - totalGamesPlayed = totalGamesPlayed + #gameResults + totalGamesPlayed = totalGamesPlayed + #gameResults - local newPlayerRating = playerRating:newRatingForRatingPeriodWithResults(gameResults) + local playerTable = players[playerID] + -- Make sure the player is up to date with the current rating period + local newPlayerRating = playerTable.playerRating:newRatingUpdatedToRatingPeriod(latestRatingPeriodFound) + newPlayerRating = newPlayerRating:newRatingForRatingPeriodWithResults(gameResults, 0) + playerTable.playerRating = newPlayerRating - if playerTable.playerRating:getRating() < 0 then - local newPlayerRating2 = playerRating:newRatingForRatingPeriodWithResults(gameResults) - end - - playerTable.playerRating = newPlayerRating - playerTable.gameResults = {} - end - - if i == firstRatingPeriod then - for playerID, playerTable in pairs(players) do - local row = {} - row[#row+1] = i - row[#row+1] = playerID - row[#row+1] = playerTable.playerRating:getRating() - row[#row+1] = playerTable.playerRating.glicko.RD - if firstRatingPeriod % 20 == 1 then - glickoResultsTable[#glickoResultsTable+1] = row - end - end + -- Save off to a table for data analysis + if lastRatingPeriodSaved == nil or latestRatingPeriodFound - lastRatingPeriodSaved >= 20 then + for playerID, playerTable in pairs(players) do + local row = {} + row[#row+1] = latestRatingPeriodFound + row[#row+1] = playerID + row[#row+1] = playerTable.playerRating:getRating() + row[#row+1] = playerTable.playerRating.glicko.RD + glickoResultsTable[#glickoResultsTable+1] = row end + lastRatingPeriodSaved = latestRatingPeriodFound end totalGamesPlayed = totalGamesPlayed / 2 - local now = os.date("*t", PlayerRating.timestampForRatingPeriod(firstRatingPeriod)) - logger.info("Processing " .. firstRatingPeriod .. " to " .. lastRatingPeriod .. " on " .. string.format("%02d/%02d/%04d", now.month, now.day, now.year) .. " with " .. totalGamesPlayed .. " games") + local now = os.date("*t", PlayerRating.timestampForRatingPeriod(latestRatingPeriodFound)) + logger.info("Processing " .. latestRatingPeriodFound .. " on " .. string.format("%02d/%02d/%04d", now.month, now.day, now.year) .. " with " .. totalGamesPlayed .. " games") + + return lastRatingPeriodSaved end -- This test is to experiment with real world server data to verify the values work well. @@ -337,8 +350,8 @@ end local function testRealWorldData() local players = {} local glickoResultsTable = {} - local ratingPeriodNeedingRun = nil - local latestRatingPeriodFound = nil + local lastRatingPeriodRun = nil + local lastRatingPeriodSaved = 0 local gamesPlayedDays = {} local gameResults = simpleCSV.read("GameResults.csv") assert(gameResults) @@ -389,17 +402,7 @@ local function testRealWorldData() goto continue end - latestRatingPeriodFound = PlayerRating.ratingPeriodForTimeStamp(timestamp) - if ratingPeriodNeedingRun == nil then - ratingPeriodNeedingRun = latestRatingPeriodFound - end - - -- if we just passed the rating period, time to update ratings - if ratingPeriodNeedingRun ~= latestRatingPeriodFound then - assert(latestRatingPeriodFound > ratingPeriodNeedingRun) - runRatingPeriods(ratingPeriodNeedingRun, latestRatingPeriodFound-1, players, glickoResultsTable) - ratingPeriodNeedingRun = latestRatingPeriodFound - end + local currentRatingPeriod = PlayerRating.ratingPeriodForTimeStamp(timestamp) local currentPlayerSets = {{player1ID, player2ID}, {player2ID, player1ID}} for _, currentPlayers in ipairs(currentPlayerSets) do @@ -409,7 +412,6 @@ local function testRealWorldData() players[playerID] = {} players[playerID].playerRating = PlayerRating() assert(players[playerID].playerRating:getRating() > 0) - players[playerID].gameResults = {} players[playerID].error = 0 players[playerID].totalGames = 0 end @@ -428,17 +430,15 @@ local function testRealWorldData() players[currentPlayers[1]].totalGames = players[currentPlayers[1]].totalGames + 1 --end local result = player:createGameResult(opponent, gameResult) - local gameResults = players[currentPlayers[1]].gameResults - gameResults[#gameResults+1] = result + local gameResults = {result} + + -- Add in the results + lastRatingPeriodSaved = runRatingPeriods(currentRatingPeriod, lastRatingPeriodSaved, players, currentPlayers[1], gameResults, glickoResultsTable) end ::continue:: end - -- Handle the last rating period - assert(ratingPeriodNeedingRun == latestRatingPeriodFound) - runRatingPeriods(ratingPeriodNeedingRun, latestRatingPeriodFound, players, glickoResultsTable) - local totalError = 0 local totalGames = 0 local provisionalCount = 0 @@ -467,4 +467,4 @@ local function testRealWorldData() simpleCSV.write("GamesPlayed.csv", gamesPlayedData) end --- testRealWorldData() \ No newline at end of file +testRealWorldData() \ No newline at end of file From 417aea0517acee684060ba87a6d32fd00d11e46e Mon Sep 17 00:00:00 2001 From: JamBox <8935453+JamesVanBoxtel@users.noreply.github.com> Date: Sat, 4 Jan 2025 12:08:40 -0800 Subject: [PATCH 4/5] Tweaked constants for reduced error --- common/data/PlayerRating.lua | 6 +++--- common/tests/data/GlickoTests.lua | 4 ++-- common/tests/data/PlayerRatingTests.lua | 23 ++++++++++++++--------- 3 files changed, 19 insertions(+), 14 deletions(-) diff --git a/common/data/PlayerRating.lua b/common/data/PlayerRating.lua index 4f26feda..502114cd 100644 --- a/common/data/PlayerRating.lua +++ b/common/data/PlayerRating.lua @@ -20,12 +20,12 @@ PlayerRating.ALLOWABLE_RATING_SPREAD = 400 PlayerRating.STARTING_RATING = 1500 -PlayerRating.STARTING_RATING_DEVIATION = 250 -PlayerRating.MAX_RATING_DEVIATION = 350 +PlayerRating.STARTING_RATING_DEVIATION = 200 +PlayerRating.MAX_RATING_DEVIATION = PlayerRating.STARTING_RATING_DEVIATION PlayerRating.PROVISIONAL_RATING_DEVIATION = 125 PlayerRating.STARTING_VOLATILITY = 0.06 -PlayerRating.MAX_VOLATILITY = PlayerRating.STARTING_VOLATILITY +PlayerRating.MAX_VOLATILITY = 0.3 -- Returns the rating period number for the given timestamp function PlayerRating.ratingPeriodForTimeStamp(timestamp) diff --git a/common/tests/data/GlickoTests.lua b/common/tests/data/GlickoTests.lua index 574c5f5c..10024852 100644 --- a/common/tests/data/GlickoTests.lua +++ b/common/tests/data/GlickoTests.lua @@ -79,8 +79,8 @@ local function orderDoesntMatter() local updatedPlayer1Copy, updatedPlayer2Copy = Glicko2.updatedRatings(player1Copy, player2Copy, {0, 1, 1, 1}, 1) - assert(updatedPlayer1Copy.Rating == updatedPlayer1.Rating) - assert(updatedPlayer2Copy.Rating == updatedPlayer2.Rating) + assert(math.floatsEqualWithPrecision(updatedPlayer1Copy.Rating, updatedPlayer1.Rating, 10)) + assert(math.floatsEqualWithPrecision(updatedPlayer2Copy.Rating, updatedPlayer2.Rating, 10)) end orderDoesntMatter() diff --git a/common/tests/data/PlayerRatingTests.lua b/common/tests/data/PlayerRatingTests.lua index af4ba1dd..f58cda38 100644 --- a/common/tests/data/PlayerRatingTests.lua +++ b/common/tests/data/PlayerRatingTests.lua @@ -25,12 +25,12 @@ testWeirdNumberStability() local function testNoMatchesInRatingPeriod() - local player1 = PlayerRating() + local player1 = PlayerRating(1500, 150) -- We shouldn't assert or blow up in here, v goes to infinity but it is okay to divide by infinity as that is 0... local updatedPlayer1 = player1:newRatingForRatingPeriodWithResults({}, 1) assert(updatedPlayer1:getRating() == 1500) -- rating shouldn't change - assert(updatedPlayer1.glicko.RD > 250) -- RD should go up + assert(updatedPlayer1.glicko.RD > 150) -- RD should go up end testNoMatchesInRatingPeriod() @@ -173,7 +173,7 @@ local function testMaxRD() playerRating = playerRating:newRatingForRatingPeriodWithResults({}, 1) end - assert(playerRating.glicko.RD >= 245) + assert(playerRating.glicko.RD >= PlayerRating.MAX_RATING_DEVIATION) end testMaxRD() @@ -246,8 +246,8 @@ local function testNewcomerSwing() assert(players[1]:getRating() > 700) -- Newcomer shouldn't go too far below other player assert(players[1]:getRating() < 800) -- Newcomer should go down significantly though - assert(players[1].glicko.RD > 80) -- Newcomer RD should start going down, but not too much - assert(players[1].glicko.RD < 90) -- Newcomer RD should start going down, but not too much + assert(players[1].glicko.RD > 70) -- Newcomer RD should start going down, but not too much + assert(players[1].glicko.RD < 150) -- Newcomer RD should start going down, but not too much assert(players[2]:getRating() > 1130) -- Normal player shouldn't gain too much rating assert(players[2]:getRating() < 1200) assert(players[2].glicko.RD > 25) -- Normal player RD should stay similar @@ -289,7 +289,7 @@ local function testSingleGameNotTooBigRatingChange() local secondRating = players[1]:getRating() local ratingDifference = secondRating - firstRating - assert(ratingDifference > 2 and ratingDifference < 4) -- Rating shouldn't change too much from one game + assert(ratingDifference > 1 and ratingDifference < 4) -- Rating shouldn't change too much from one game end testSingleGameNotTooBigRatingChange() @@ -331,6 +331,7 @@ local function runRatingPeriods(latestRatingPeriodFound, lastRatingPeriodSaved, row[#row+1] = playerID row[#row+1] = playerTable.playerRating:getRating() row[#row+1] = playerTable.playerRating.glicko.RD + row[#row+1] = playerTable.playerRating.glicko.Vol glickoResultsTable[#glickoResultsTable+1] = row end lastRatingPeriodSaved = latestRatingPeriodFound @@ -457,8 +458,12 @@ local function testRealWorldData() local totalErrorPerGame = totalError / totalGames simpleCSV.write("Glicko.csv", glickoResultsTable) - -- 0.03724587514630 RATING PERIOD = 16hrs -- DEFAULT_RATING_DEVIATION = 250 = MAX_DEVIATION -- PROVISIONAL_DEVIATION = RD * 0.5 -- DEFAULT_VOLATILITY = 0.06 = MAX_VOLATILITY - -- 0.03792533617676 RATING PERIOD = 24hrs -- DEFAULT_RATING_DEVIATION = 250 = MAX_DEVIATION -- PROVISIONAL_DEVIATION = RD * 0.5 -- DEFAULT_VOLATILITY = 0.06 = MAX_VOLATILITY + -- 0.014760291450591 1 game per evaluation DEFAULT_RATING_DEVIATION:200 MAX_DEVIATION:200 DEFAULT_VOLATILITY:0.06 MAX_VOLATILITY:0.3 Tau:0.5 RATING PERIOD = 16hrs + -- 0.019420264639828 1 game per evaluation DEFAULT_RATING_DEVIATION:250 MAX_DEVIATION:250 DEFAULT_VOLATILITY:0.06 MAX_VOLATILITY:0.3 Tau:0.2 RATING PERIOD = 16hrs + -- 0.019439095055957 1 game per evaluation DEFAULT_RATING_DEVIATION:250 MAX_DEVIATION:250 DEFAULT_VOLATILITY:0.06 MAX_VOLATILITY:0.3 Tau:0.75 RATING PERIOD = 16hrs + -- 0.01944363557018 1 game per evaluation DEFAULT_RATING_DEVIATION:250 MAX_DEVIATION:350 DEFAULT_VOLATILITY:0.06 MAX_VOLATILITY:0.06 Tau:? RATING PERIOD = 16hrs + -- 0.03724587514630 all games in rating period DEFAULT_RATING_DEVIATION:250 MAX_DEVIATION:250 DEFAULT_VOLATILITY:0.06 MAX_VOLATILITY:0.06 Tau:? RATING PERIOD = 16hrs + -- 0.03792533617676 all games in rating period DEFAULT_RATING_DEVIATION:250 MAX_DEVIATION:250 DEFAULT_VOLATILITY:0.06 MAX_VOLATILITY:0.06 Tau:? RATING PERIOD = 24hrs local gamesPlayedData = {} for dateKey, data in pairsSortedByKeys(gamesPlayedDays) do @@ -467,4 +472,4 @@ local function testRealWorldData() simpleCSV.write("GamesPlayed.csv", gamesPlayedData) end -testRealWorldData() \ No newline at end of file +-- testRealWorldData() \ No newline at end of file From 5ef49628b52d2ce1aafc717c02e4961e3a95237c Mon Sep 17 00:00:00 2001 From: JamBox <8935453+JamesVanBoxtel@users.noreply.github.com> Date: Sun, 5 Jan 2025 18:52:35 -0800 Subject: [PATCH 5/5] Fix tests This fixes all tests to be more accurate to a real world scenario where matches happen one at at time on both players before the player table is updated. It also fixes that bug along with the bug of not updating the RD after the last game a player played. As far as I know all feature work is done and tested well. --- common/data/PlayerRating.lua | 67 ++-- common/tests/data/PlayerRatingTests.lua | 506 +++++++++++------------- 2 files changed, 278 insertions(+), 295 deletions(-) diff --git a/common/data/PlayerRating.lua b/common/data/PlayerRating.lua index 502114cd..32d9eeb2 100644 --- a/common/data/PlayerRating.lua +++ b/common/data/PlayerRating.lua @@ -58,30 +58,26 @@ function PlayerRating:isProvisional() return self.glicko.RD >= PlayerRating.PROVISIONAL_RATING_DEVIATION end --- Returns an array of result objects representing the players wins against the given player +-- Returns an array of wins vs total games +-- The wins / losses will be distributed by the ratio to try to balance it out. -- Really only meant for testing. -function PlayerRating:createSetResults(opponent, player1WinCount, gameCount) +function PlayerRating.createTestSetResults(player1WinCount, gameCount) assert(gameCount >= player1WinCount) local matchSet = {} - for i = 1, player1WinCount, 1 do - matchSet[#matchSet+1] = 1 - end for i = 1, gameCount - player1WinCount, 1 do matchSet[#matchSet+1] = 0 end - - local player1Results = {} - for j = 1, #matchSet do -- play through games - local matchOutcome = matchSet[j] - local gameResult = self:createGameResult(opponent, matchOutcome) - if gameResult then - player1Results[#player1Results+1] = gameResult - end + + local step = gameCount / player1WinCount + local position = 1 + for i = 1, player1WinCount, 1 do + matchSet[math.round(position)] = 1 + position = position + step end - return player1Results + return matchSet end -- Helper function to create one game result with the given outcome if the players are allowed to rank. @@ -106,24 +102,41 @@ function PlayerRating.invertedGameResult(gameResult) return gameResult end --- Returns the rating period number for the given timestamp -function PlayerRating:newRatingUpdatedToRatingPeriod(ratingPeriod) - local updatedPlayer = self:copy() +function PlayerRating:newRatingForResultsAndLatestRatingPeriod(gameResult, latestRatingPeriodFound) + local updatedPlayer = self if updatedPlayer.lastRatingPeriodCalculated == nil then - updatedPlayer.lastRatingPeriodCalculated = ratingPeriod - return updatedPlayer - elseif updatedPlayer.lastRatingPeriodCalculated >= ratingPeriod then - assert(false, "Trying to update to rating before already calculated") + updatedPlayer = self:copy() + updatedPlayer.lastRatingPeriodCalculated = latestRatingPeriodFound + end + local elapsedRatingPeriods = latestRatingPeriodFound - updatedPlayer.lastRatingPeriodCalculated + if elapsedRatingPeriods > 0 then + updatedPlayer = updatedPlayer:newRatingForResultsAndElapsedRatingPeriod(gameResult, elapsedRatingPeriods) + end + + return updatedPlayer +end + +-- Runs the given results for the player with the given elapsedRatingPeriod +function PlayerRating:newRatingForResultsAndElapsedRatingPeriod(gameResult, elapsedRatingPeriods) + local updatedPlayer = self + + if elapsedRatingPeriods > 0 then + updatedPlayer = updatedPlayer:privateNewRatingForResultWithElapsedRatingPeriod(nil, elapsedRatingPeriods) + if updatedPlayer.lastRatingPeriodCalculated then + updatedPlayer.lastRatingPeriodCalculated = self.lastRatingPeriodCalculated + elapsedRatingPeriods + end end - local elapsedRatingPeriods = ratingPeriod - updatedPlayer.lastRatingPeriodCalculated - updatedPlayer = updatedPlayer:newRatingForRatingPeriodWithResults({}, elapsedRatingPeriods) - updatedPlayer.lastRatingPeriodCalculated = self.lastRatingPeriodCalculated + elapsedRatingPeriods + + updatedPlayer = updatedPlayer:privateNewRatingForResultWithElapsedRatingPeriod(gameResult, 0) + return updatedPlayer end --- Runs one "rating period" with the given results for the player. --- To get the accurate rating of a player, this must be run on every rating period since the last time they were updated. -function PlayerRating:newRatingForRatingPeriodWithResults(gameResults, elapsedRatingPeriods) +function PlayerRating:privateNewRatingForResultWithElapsedRatingPeriod(gameResult, elapsedRatingPeriods) + local gameResults = {} + if gameResult then + gameResults[#gameResults+1] = gameResult + end local updatedGlicko = self.glicko:update(gameResults, elapsedRatingPeriods) if updatedGlicko.RD > self.maxRatingDeviation then updatedGlicko.RD = self.maxRatingDeviation diff --git a/common/tests/data/PlayerRatingTests.lua b/common/tests/data/PlayerRatingTests.lua index f58cda38..e8483519 100644 --- a/common/tests/data/PlayerRatingTests.lua +++ b/common/tests/data/PlayerRatingTests.lua @@ -3,166 +3,233 @@ local simpleCSV = require("server.simplecsv") local tableUtils = require("common.lib.tableUtils") local logger = require("common.lib.logger") --- If starting RD is too high, or too many matches happen in one rating period, massive swings can happen. --- This test is to explore that and come up with sane values. -local function testWeirdNumberStability() +local function createPlayer(playerTable, playerID, playerRating) + playerTable[playerID] = {} + playerTable[playerID].playerRating = playerRating + assert(playerTable[playerID].playerRating:getRating() > 0) + playerTable[playerID].error = 0 + playerTable[playerID].totalGames = 0 +end + +local function playGamesWithResults(playerTable, player1ID, player2ID, gameResults, ratingPeriodElapsed) + for _, gameResult in ipairs(gameResults) do + local player1Rating = playerTable[player1ID].playerRating + local player2Rating = playerTable[player2ID].playerRating + for i = 1, 2, 1 do + local playerID = player1ID + local playerRating = player1Rating + local opponentRating = player2Rating + if i == 2 then + playerID = player2ID + playerRating = player2Rating + opponentRating = player1Rating + gameResult = PlayerRating.invertedGameResult(gameResult) + end + local result = playerRating:createGameResult(opponentRating, gameResult) + local expected = playerRating:expectedOutcome(opponentRating) + + playerTable[playerID].playerRating = playerRating:newRatingForResultsAndElapsedRatingPeriod(result, ratingPeriodElapsed) + playerTable[playerID].error = playerTable[playerID].error + (gameResult - expected) + playerTable[playerID].totalGames = playerTable[playerID].totalGames + 1 + end + end +end - local player1 = PlayerRating(1273, 20) +local function applyNewRatingPeriodToPlayers(playerTable, ratingPeriod) + for _, playerRow in ipairs(playerTable) do + playerRow.playerRating = playerRow.playerRating:newRatingForResultsAndLatestRatingPeriod(nil, ratingPeriod) + end +end + +local function testLowRDSimilarOpponents() + + local player1 = PlayerRating(1490, 20) local player2 = PlayerRating(1500, 20) + local players = {} + createPlayer(players, 1, player1) + createPlayer(players, 2, player2) + + playGamesWithResults(players, 1, 2, PlayerRating.createTestSetResults(1, 1), 0) + + local player1RatingChange = players[1].playerRating:getRating() - player1:getRating() + local player2RatingChange = players[2].playerRating:getRating() - player2:getRating() + assert(player1RatingChange > 1 and player1RatingChange < 2) + assert(player2RatingChange < -1 and player2RatingChange > -2) +end +testLowRDSimilarOpponents() + +local function testLowRDFarOpponentUpset() + + local player1 = PlayerRating(1110, 20) + local player2 = PlayerRating(1500, 20) + local players = {} + createPlayer(players, 1, player1) + createPlayer(players, 2, player2) + + playGamesWithResults(players, 1, 2, PlayerRating.createTestSetResults(1, 1), 0) + + local player1RatingChange = players[1].playerRating:getRating() - player1:getRating() + local player2RatingChange = players[2].playerRating:getRating() - player2:getRating() + assert(player1RatingChange > 2 and player1RatingChange < 3) + assert(player2RatingChange < -2 and player2RatingChange > -3) +end +testLowRDFarOpponentUpset() + +local function testLowRDFarOpponentExpected() + local player1 = PlayerRating(1110, 20) + local player2 = PlayerRating(1500, 20) + local players = {} + createPlayer(players, 1, player1) + createPlayer(players, 2, player2) + + playGamesWithResults(players, 1, 2, PlayerRating.createTestSetResults(0, 1), 0) + + local player1RatingChange = players[1].playerRating:getRating() - player1:getRating() + local player2RatingChange = players[2].playerRating:getRating() - player2:getRating() + assert(player1RatingChange < -0.2 and player1RatingChange > -0.3) + assert(player2RatingChange > .2 and player2RatingChange < .3) +end +testLowRDFarOpponentExpected() + +local function testHighRDSimilarOpponents() + local player1 = PlayerRating(1490, PlayerRating.STARTING_RATING_DEVIATION) + local player2 = PlayerRating(1500, PlayerRating.STARTING_RATING_DEVIATION) + local players = {} + createPlayer(players, 1, player1) + createPlayer(players, 2, player2) + + playGamesWithResults(players, 1, 2, PlayerRating.createTestSetResults(1, 1), 0) - local wins = 12 - local totalGames = 25 + local player1RatingChange = players[1].playerRating:getRating() - player1:getRating() + local player2RatingChange = players[2].playerRating:getRating() - player2:getRating() + assert(player1RatingChange > 80 and player1RatingChange < 100) + assert(player2RatingChange < -80 and player2RatingChange > -100) +end +testHighRDSimilarOpponents() + +local function testNewcomerUpset() + local player1 = PlayerRating() + local player2 = PlayerRating(1890, 20) + local players = {} + createPlayer(players, 1, player1) + createPlayer(players, 2, player2) - local updatedPlayer1 = player1:newRatingForRatingPeriodWithResults(player1:createSetResults(player2, wins, totalGames), 1) - local updatedPlayer2 = player2:newRatingForRatingPeriodWithResults(player1:createSetResults(player1, totalGames-wins, totalGames), 1) + playGamesWithResults(players, 1, 2, PlayerRating.createTestSetResults(1, 1), 0) - assert(updatedPlayer1:getRating() > 1073) - assert(updatedPlayer2:getRating() < 1500) - assert(updatedPlayer2:getRating() > 1338) + local player1RatingChange = players[1].playerRating:getRating() - player1:getRating() + local player2RatingChange = players[2].playerRating:getRating() - player2:getRating() + assert(player1RatingChange > 180 and player1RatingChange < 200) + assert(player2RatingChange < -1 and player2RatingChange > -5) end +testNewcomerUpset() -testWeirdNumberStability() +local function testNewcomerExpected() + local player1 = PlayerRating() + local player2 = PlayerRating(1890, 20) + local players = {} + createPlayer(players, 1, player1) + createPlayer(players, 2, player2) + + playGamesWithResults(players, 1, 2, PlayerRating.createTestSetResults(0, 1), 0) + + local player1RatingChange = players[1].playerRating:getRating() - player1:getRating() + local player2RatingChange = players[2].playerRating:getRating() - player2:getRating() + assert(player1RatingChange < -19 and player1RatingChange > -20) + assert(player2RatingChange > .2 and player2RatingChange < .3) +end +testNewcomerExpected() -local function testNoMatchesInRatingPeriod() +local function testNoMatchesInRatingPeriod() local player1 = PlayerRating(1500, 150) -- We shouldn't assert or blow up in here, v goes to infinity but it is okay to divide by infinity as that is 0... - local updatedPlayer1 = player1:newRatingForRatingPeriodWithResults({}, 1) + local updatedPlayer1 = player1:newRatingForResultsAndElapsedRatingPeriod({}, 1) assert(updatedPlayer1:getRating() == 1500) -- rating shouldn't change assert(updatedPlayer1.glicko.RD > 150) -- RD should go up end - testNoMatchesInRatingPeriod() -local function testRatingPeriodsForOccasionalPlayers() +local function testRatingPeriodsForOccasionalPlayers() local players = {} - for _ = 1, 3 do - players[#players+1] = PlayerRating() + for i = 1, 3 do + createPlayer(players, i, PlayerRating()) end - - local previousPlayers = nil + + local ratingPeriodBetweenSets = 24 * 60 * 60 / PlayerRating.RATING_PERIOD_IN_SECONDS + local previousPlayerRatings = {} for i = 1, 100, 1 do - local playerResults = {} - for _ = 1, 3 do - playerResults[#playerResults+1] = {} - end - local gameCount = 10 - local winPercentage = .6 - local winCount = math.ceil(winPercentage*gameCount) - tableUtils.appendToList(playerResults[1], players[1]:createSetResults(players[2], winCount, gameCount)) - tableUtils.appendToList(playerResults[2], players[2]:createSetResults(players[1], gameCount-winCount, gameCount)) - - gameCount = 5 - winPercentage = .8 - winCount = math.ceil(winPercentage*gameCount) - tableUtils.appendToList(playerResults[1], players[1]:createSetResults(players[3], winCount, gameCount)) - tableUtils.appendToList(playerResults[3], players[3]:createSetResults(players[1], gameCount-winCount, gameCount)) - - gameCount = 5 - winPercentage = .6 - winCount = math.ceil(winPercentage*gameCount) - tableUtils.appendToList(playerResults[2], players[2]:createSetResults(players[3], winCount, gameCount)) - tableUtils.appendToList(playerResults[3], players[3]:createSetResults(players[2], gameCount-winCount, gameCount)) - - previousPlayers = {} for k = 1, 3 do - previousPlayers[#previousPlayers+1] = players[k]:copy() + previousPlayerRatings[k] = players[k].playerRating:copy() end - for k = 1, 3 do - players[k] = players[k]:newRatingForRatingPeriodWithResults(playerResults[k], 1) - end - end + playGamesWithResults(players, 1, 2, PlayerRating.createTestSetResults(6, 10), 0) + playGamesWithResults(players, 1, 3, PlayerRating.createTestSetResults(4, 5), 0) + playGamesWithResults(players, 2, 3, PlayerRating.createTestSetResults(3, 5), 0) - assert(players[1]:getRating() > players[2]:getRating()) - assert(players[1]:getRating() > players[3]:getRating()) - assert(players[2]:getRating() > players[3]:getRating()) + applyNewRatingPeriodToPlayers(players, i * ratingPeriodBetweenSets) + end - assert(players[1].glicko.RD < players[3].glicko.RD) - assert(players[2].glicko.RD < players[3].glicko.RD) + assert(players[1].playerRating:getRating() > players[2].playerRating:getRating()) + assert(players[1].playerRating:getRating() > players[3].playerRating:getRating()) + assert(players[2].playerRating:getRating() > players[3].playerRating:getRating()) - assert(players[1].glicko.RD < 60) - assert(players[2].glicko.RD < 60) - assert(players[3].glicko.RD < 60) + assert(players[1].playerRating.glicko.RD < 60) + assert(players[2].playerRating.glicko.RD < 60) + assert(players[3].playerRating.glicko.RD < 60) for k = 1, 3 do -- rating and deviation should stabilize over time if players perform the same - assert(math.abs(previousPlayers[k]:getRating() - players[k]:getRating()) < 1) - assert(math.abs(previousPlayers[k].glicko.RD - players[k].glicko.RD) < 1) - assert(previousPlayers[k]:isProvisional() == false) + assert(math.abs(previousPlayerRatings[k]:getRating() - players[k].playerRating:getRating()) < 6) + assert(math.abs(previousPlayerRatings[k].glicko.RD - players[k].playerRating.glicko.RD) < 1) + assert(previousPlayerRatings[k]:isProvisional() == false) end -end - +end testRatingPeriodsForOccasionalPlayers() local function testRatingPeriodsForObsessivePlayers() local players = {} - for _ = 1, 3 do - players[#players+1] = PlayerRating() + for i = 1, 3 do + createPlayer(players, i, PlayerRating()) end - local previousPlayers = nil + local ratingPeriodBetweenSets = 24 * 60 * 60 / PlayerRating.RATING_PERIOD_IN_SECONDS + local previousPlayerRatings = {} for i = 1, 100, 1 do - local playerResults = {} - for _ = 1, 3 do - playerResults[#playerResults+1] = {} - end - local gameCount = 100 - local winPercentage = .6 - local winCount = math.ceil(winPercentage*gameCount) - tableUtils.appendToList(playerResults[1], players[1]:createSetResults(players[2], winCount, gameCount)) - tableUtils.appendToList(playerResults[2], players[2]:createSetResults(players[1], gameCount-winCount, gameCount)) - - gameCount = 80 - winPercentage = .8 - winCount = math.ceil(winPercentage*gameCount) - tableUtils.appendToList(playerResults[1], players[1]:createSetResults(players[3], winCount, gameCount)) - tableUtils.appendToList(playerResults[3], players[3]:createSetResults(players[1], gameCount-winCount, gameCount)) - - gameCount = 60 - winPercentage = .6 - winCount = math.ceil(winPercentage*gameCount) - tableUtils.appendToList(playerResults[2], players[2]:createSetResults(players[3], winCount, gameCount)) - tableUtils.appendToList(playerResults[3], players[3]:createSetResults(players[2], gameCount-winCount, gameCount)) - - previousPlayers = {} for k = 1, 3 do - previousPlayers[#previousPlayers+1] = players[k]:copy() + previousPlayerRatings[k] = players[k].playerRating:copy() end - for k = 1, 3 do - players[k] = players[k]:newRatingForRatingPeriodWithResults(playerResults[k], 1) - end - end + playGamesWithResults(players, 1, 2, PlayerRating.createTestSetResults(60, 100), 0) + playGamesWithResults(players, 1, 3, PlayerRating.createTestSetResults(40, 50), 0) + playGamesWithResults(players, 2, 3, PlayerRating.createTestSetResults(30, 50), 0) - assert(players[1]:getRating() > players[2]:getRating()) - assert(players[1]:getRating() > players[3]:getRating()) - assert(players[2]:getRating() > players[3]:getRating()) + applyNewRatingPeriodToPlayers(players, i * ratingPeriodBetweenSets) + end - assert(players[1].glicko.RD < players[3].glicko.RD) - assert(players[2].glicko.RD < players[3].glicko.RD) + assert(players[1].playerRating:getRating() > players[2].playerRating:getRating()) + assert(players[1].playerRating:getRating() > players[3].playerRating:getRating()) + assert(players[2].playerRating:getRating() > players[3].playerRating:getRating()) - assert(players[1].glicko.RD < 20) - assert(players[2].glicko.RD < 20) - assert(players[3].glicko.RD < 20) + assert(players[1].playerRating.glicko.RD < 30) + assert(players[2].playerRating.glicko.RD < 30) + assert(players[3].playerRating.glicko.RD < 35) -- player 3 "only" played 100 games per day for k = 1, 3 do -- rating and deviation should stabilize over time if players perform the same - assert(math.abs(previousPlayers[k]:getRating() - players[k]:getRating()) < 1) - assert(math.abs(previousPlayers[k].glicko.RD - players[k].glicko.RD) < 1) - assert(previousPlayers[k]:isProvisional() == false) + assert(math.abs(previousPlayerRatings[k]:getRating() - players[k].playerRating:getRating()) < 6) + assert(math.abs(previousPlayerRatings[k].glicko.RD - players[k].playerRating.glicko.RD) < 1) + assert(previousPlayerRatings[k]:isProvisional() == false) end end - testRatingPeriodsForObsessivePlayers() -- When a stable player doesn't play for a long time, we should lose some confidence in their rating, but not all. -local function testMaxRD() +local function testMaxRD() local playerRating = PlayerRating(2000, 30) local threeMonthsInSeconds = 60 * 60 * 24 * 31 * 3 local threeMonthsOfRatingPeriod = math.ceil(threeMonthsInSeconds / PlayerRating.RATING_PERIOD_IN_SECONDS) for i = 1, threeMonthsOfRatingPeriod, 1 do - playerRating = playerRating:newRatingForRatingPeriodWithResults({}, 1) + playerRating = playerRating:newRatingForResultsAndElapsedRatingPeriod({}, 1) end assert(playerRating.glicko.RD >= 120) @@ -170,130 +237,55 @@ local function testMaxRD() local nineMoreMonths = 60 * 60 * 24 * 31 * 9 local oneYearOfRatingPeriod = math.ceil(nineMoreMonths / PlayerRating.RATING_PERIOD_IN_SECONDS) for i = 1, oneYearOfRatingPeriod, 1 do - playerRating = playerRating:newRatingForRatingPeriodWithResults({}, 1) + playerRating = playerRating:newRatingForResultsAndElapsedRatingPeriod({}, 1) end assert(playerRating.glicko.RD >= PlayerRating.MAX_RATING_DEVIATION) -end - +end testMaxRD() local function testFarming() local players = {} - for _ = 1, 2 do - players[#players+1] = PlayerRating() + for i = 1, 2 do + createPlayer(players, i, PlayerRating()) end - -- Player 1 and 2 play normal sets to get a standard + local ratingPeriodBetweenSets = 24 * 60 * 60 / PlayerRating.RATING_PERIOD_IN_SECONDS for i = 1, 100, 1 do - local playerResults = {} - for _ = 1, 2 do - playerResults[#playerResults+1] = {} - end - - tableUtils.appendToList(playerResults[1], players[1]:createSetResults(players[2], 11, 20)) - tableUtils.appendToList(playerResults[2], players[2]:createSetResults(players[1], 9, 20)) - - for k = 1, 2 do - players[k] = players[k]:newRatingForRatingPeriodWithResults(playerResults[k], 1) - end + playGamesWithResults(players, 1, 2, PlayerRating.createTestSetResults(11, 20), 0) + applyNewRatingPeriodToPlayers(players, i * ratingPeriodBetweenSets) end -- Farm newcomers to see how much rating you can gain - for i = 1, 100, 1 do - local playerResults = {} - for _ = 1, 1 do - playerResults[#playerResults+1] = {} - end - - local newbiePlayer = PlayerRating() - tableUtils.appendToList(playerResults[1], players[1]:createSetResults(newbiePlayer, 10, 10)) - - for k = 1, 1 do - players[k] = players[k]:newRatingForRatingPeriodWithResults(playerResults[k], 1) - end + for i = 3, 1000, 1 do + createPlayer(players, i, PlayerRating()) + playGamesWithResults(players, 1, i, PlayerRating.createTestSetResults(1, 1), 0) end - assert(players[1]:getRating() > PlayerRating.STARTING_RATING + PlayerRating.ALLOWABLE_RATING_SPREAD) -- Ranked high enough we can't play default players anymore - assert(players[1]:getRating() < 2000) -- Thus we couldn't farm really high - -end - + assert(players[1].playerRating:getRating() > PlayerRating.STARTING_RATING + PlayerRating.ALLOWABLE_RATING_SPREAD) -- Ranked high enough we can't play default players anymore + assert(players[1].playerRating:getRating() < 2000) -- Thus we couldn't farm really high +end testFarming() local function testNewcomerSwing() local players = {} - players[#players+1] = PlayerRating() - players[#players+1] = PlayerRating(1121.96, 29.65) + createPlayer(players, 1, PlayerRating()) + createPlayer(players, 2, PlayerRating(1121.96, 29.65)) -- Newcomer loses 40 times in a row... - local gameCount = 1 - local setCount = 40 - local setRatingPeriodLength = 1 / 24 / 60 -- 1 mins of playing - for i = 1, setCount, 1 do - local playerResults = {} - for _ = 1, 2 do - playerResults[#playerResults+1] = {} - end - - tableUtils.appendToList(playerResults[1], players[1]:createSetResults(players[2], 0, gameCount)) - tableUtils.appendToList(playerResults[2], players[2]:createSetResults(players[1], gameCount, gameCount)) - - for k = 1, 2 do - players[k] = players[k]:newRatingForRatingPeriodWithResults(playerResults[k], setRatingPeriodLength) - end - end - - assert(players[1]:getRating() > 700) -- Newcomer shouldn't go too far below other player - assert(players[1]:getRating() < 800) -- Newcomer should go down significantly though - assert(players[1].glicko.RD > 70) -- Newcomer RD should start going down, but not too much - assert(players[1].glicko.RD < 150) -- Newcomer RD should start going down, but not too much - assert(players[2]:getRating() > 1130) -- Normal player shouldn't gain too much rating - assert(players[2]:getRating() < 1200) - assert(players[2].glicko.RD > 25) -- Normal player RD should stay similar - assert(players[2].glicko.RD < 30) -end - + playGamesWithResults(players, 1, 2, PlayerRating.createTestSetResults(0, 40), 0) + + assert(players[1].playerRating:getRating() > 700) -- Newcomer shouldn't go too far below other player + assert(players[1].playerRating:getRating() < 800) -- Newcomer should go down significantly though + assert(players[1].playerRating.glicko.RD > 70) -- Newcomer RD should start going down, but not too much + assert(players[1].playerRating.glicko.RD < 150) -- Newcomer RD should start going down, but not too much + assert(players[2].playerRating:getRating() > 1130) -- Normal player shouldn't gain too much rating + assert(players[2].playerRating:getRating() < 1200) + assert(players[2].playerRating.glicko.RD > 25) -- Normal player RD should stay similar + assert(players[2].playerRating.glicko.RD < 30) +end testNewcomerSwing() - - - -local function testSingleGameNotTooBigRatingChange() - local players = {} - for _ = 1, 2 do - players[#players+1] = PlayerRating() - end - - -- Player 1 and 2 play normal sets to get a standard - for i = 1, 100, 1 do - local playerResults = {} - for _ = 1, 2 do - playerResults[#playerResults+1] = {} - end - - tableUtils.appendToList(playerResults[1], players[1]:createSetResults(players[2], 11, 20)) - tableUtils.appendToList(playerResults[2], players[2]:createSetResults(players[1], 9, 20)) - - for k = 1, 2 do - players[k] = players[k]:newRatingForRatingPeriodWithResults(playerResults[k], 1) - end - end - - local firstRating = players[1]:getRating() - assert(firstRating > 1515 and firstRating < 1525) - - local playerResults = {} - tableUtils.appendToList(playerResults, players[1]:createSetResults(players[2], 1, 1)) - players[1] = players[1]:newRatingForRatingPeriodWithResults(playerResults, 1) - - local secondRating = players[1]:getRating() - local ratingDifference = secondRating - firstRating - assert(ratingDifference > 1 and ratingDifference < 4) -- Rating shouldn't change too much from one game -end - -testSingleGameNotTooBigRatingChange() - local usedNames = {} local publicIDMap = {} -- mapping of privateID to publicID local function cleanNameForName(name, privateID) @@ -311,38 +303,25 @@ local function cleanNameForName(name, privateID) return publicIDMap[privateID] end -local function runRatingPeriods(latestRatingPeriodFound, lastRatingPeriodSaved, players, playerID, gameResults, glickoResultsTable) - - local totalGamesPlayed = 0 - - totalGamesPlayed = totalGamesPlayed + #gameResults - - local playerTable = players[playerID] - -- Make sure the player is up to date with the current rating period - local newPlayerRating = playerTable.playerRating:newRatingUpdatedToRatingPeriod(latestRatingPeriodFound) - newPlayerRating = newPlayerRating:newRatingForRatingPeriodWithResults(gameResults, 0) +local function saveToResultsTable(currentRatingPeriod, players, glickoResultsTable) + for playerID, playerTable in pairs(players) do + -- Make sure the player is up to date with the current rating period + local newPlayerRating = playerTable.playerRating:newRatingForResultsAndLatestRatingPeriod({}, currentRatingPeriod) playerTable.playerRating = newPlayerRating - - -- Save off to a table for data analysis - if lastRatingPeriodSaved == nil or latestRatingPeriodFound - lastRatingPeriodSaved >= 20 then - for playerID, playerTable in pairs(players) do - local row = {} - row[#row+1] = latestRatingPeriodFound - row[#row+1] = playerID - row[#row+1] = playerTable.playerRating:getRating() - row[#row+1] = playerTable.playerRating.glicko.RD - row[#row+1] = playerTable.playerRating.glicko.Vol - glickoResultsTable[#glickoResultsTable+1] = row - end - lastRatingPeriodSaved = latestRatingPeriodFound + local row = {} + row[#row+1] = playerTable.playerRating.lastRatingPeriodCalculated + row[#row+1] = playerID + row[#row+1] = playerTable.playerRating:getRating() + row[#row+1] = playerTable.playerRating.glicko.RD + row[#row+1] = playerTable.playerRating.glicko.Vol + row[#row+1] = playerTable.error + row[#row+1] = playerTable.totalGames + glickoResultsTable[#glickoResultsTable+1] = row end +end - totalGamesPlayed = totalGamesPlayed / 2 - - local now = os.date("*t", PlayerRating.timestampForRatingPeriod(latestRatingPeriodFound)) - logger.info("Processing " .. latestRatingPeriodFound .. " on " .. string.format("%02d/%02d/%04d", now.month, now.day, now.year) .. " with " .. totalGamesPlayed .. " games") +local function runRatingPeriods(latestRatingPeriodFound, lastRatingPeriodSaved, players, playerID, gameResults, glickoResultsTable) - return lastRatingPeriodSaved end -- This test is to experiment with real world server data to verify the values work well. @@ -351,19 +330,17 @@ end local function testRealWorldData() local players = {} local glickoResultsTable = {} - local lastRatingPeriodRun = nil local lastRatingPeriodSaved = 0 + local lastRatingPeriodFound = 0 local gamesPlayedDays = {} local gameResults = simpleCSV.read("GameResults.csv") assert(gameResults) - local playersFile, err = love.filesystem.newFile("players.txt", "r") - if playersFile then - local tehJSON = playersFile:read(playersFile:getSize()) - playersFile:close() - playersFile = nil - local playerData = json.decode(tehJSON) or {} + local tehJSON, err = love.filesystem.read("players.txt") + if tehJSON then + local playerData = json.decode(tehJSON) if playerData then + ---@cast playerData table for key, value in pairs(playerData) do cleanNameForName(value, key) end @@ -404,42 +381,35 @@ local function testRealWorldData() end local currentRatingPeriod = PlayerRating.ratingPeriodForTimeStamp(timestamp) + lastRatingPeriodFound = currentRatingPeriod + + local now = os.date("*t", PlayerRating.timestampForRatingPeriod(currentRatingPeriod)) + logger.info("Processing " .. currentRatingPeriod .. " on " .. string.format("%02d/%02d/%04d", now.month, now.day, now.year)) local currentPlayerSets = {{player1ID, player2ID}, {player2ID, player1ID}} - for _, currentPlayers in ipairs(currentPlayerSets) do - local playerID = currentPlayers[1] + for _, playerID in ipairs(currentPlayerSets[1]) do -- Create a new player if one doesn't exist yet. if not players[playerID] then - players[playerID] = {} - players[playerID].playerRating = PlayerRating() - assert(players[playerID].playerRating:getRating() > 0) - players[playerID].error = 0 - players[playerID].totalGames = 0 + createPlayer(players, playerID, PlayerRating()) end + players[playerID].playerRating = players[playerID].playerRating:newRatingForResultsAndLatestRatingPeriod(nil, currentRatingPeriod) end - for index, currentPlayers in ipairs(currentPlayerSets) do - local player = players[currentPlayers[1]].playerRating - local opponent = players[currentPlayers[2]].playerRating - local gameResult = winResult - if index == 2 then - gameResult = PlayerRating.invertedGameResult(winResult) - end - local expected = player:expectedOutcome(opponent) - --if player:isProvisional() == false then - players[currentPlayers[1]].error = players[currentPlayers[1]].error + (gameResult - expected) - players[currentPlayers[1]].totalGames = players[currentPlayers[1]].totalGames + 1 - --end - local result = player:createGameResult(opponent, gameResult) - local gameResults = {result} - - -- Add in the results - lastRatingPeriodSaved = runRatingPeriods(currentRatingPeriod, lastRatingPeriodSaved, players, currentPlayers[1], gameResults, glickoResultsTable) + playGamesWithResults(players, player1ID, player2ID, {winResult}, 0) + + -- Save off to a table for data analysis + local periodDifferenceFromLastRecord = currentRatingPeriod - lastRatingPeriodSaved + local shouldRecordRatings = (periodDifferenceFromLastRecord > 10) or (periodDifferenceFromLastRecord >= 1 and (currentRatingPeriod > 30130 or currentRatingPeriod < 29440)) + if lastRatingPeriodSaved == nil or shouldRecordRatings then + saveToResultsTable(currentRatingPeriod, players, glickoResultsTable) + lastRatingPeriodSaved = currentRatingPeriod end ::continue:: end + saveToResultsTable(lastRatingPeriodFound, players, glickoResultsTable) + local totalError = 0 local totalGames = 0 local provisionalCount = 0 @@ -470,6 +440,6 @@ local function testRealWorldData() gamesPlayedData[#gamesPlayedData+1] = data end simpleCSV.write("GamesPlayed.csv", gamesPlayedData) -end +end --- testRealWorldData() \ No newline at end of file +testRealWorldData() \ No newline at end of file