From 09e816b58a5cc551801d2cf4c418bd26a954a014 Mon Sep 17 00:00:00 2001 From: Gabe Farrell Date: Fri, 15 Mar 2024 04:46:45 +0000 Subject: [PATCH] first commit --- ELO.md | 59 +++++++ README.md | 116 +++++++++++++ calculator.go | 114 +++++++++++++ calculator_test.go | 82 ++++++++++ go.mod | 3 + match.go | 188 +++++++++++++++++++++ match_test.go | 173 ++++++++++++++++++++ strategy.go | 95 +++++++++++ strategy_test.go | 396 +++++++++++++++++++++++++++++++++++++++++++++ 9 files changed, 1226 insertions(+) create mode 100644 ELO.md create mode 100644 README.md create mode 100644 calculator.go create mode 100644 calculator_test.go create mode 100644 go.mod create mode 100644 match.go create mode 100644 match_test.go create mode 100644 strategy.go create mode 100644 strategy_test.go diff --git a/ELO.md b/ELO.md new file mode 100644 index 0000000..0db1496 --- /dev/null +++ b/ELO.md @@ -0,0 +1,59 @@ +# How Elo is Calculated + +## Calculating the expected chance to win +The probability a player has to win is calculated as follows: + +$R_1=10^{Elo_1/V}$ + +$R_2=10^{Elo_2/V}$ + +$E_1=R_1/(R_1+R_2)$ + +$E_2=R_2/(R_1+R_2)$ + +Where: +- $Elo_1$ is the elo of player one, +- $Elo_2$ is the elo of player two, +- $V$ is the deviation parameter, +- $E_1$ is the probabilty of player one winning, and +- $E_2$ is the probability of player two winning. + +There are two factors that influence the probability that a player has to win: the difference in elo, and the deviation parameter. The greater the difference in elo, the more likely it is for the higher-elo player to win, whereas the greater the deviation, the less likely the higher elo player to win. + + +## Unscored (win/loss) matches +Scored matches have two factors that determines how much the players' elo will change after the match. These variables are the chance the winner had to win, and the other is the $K$-Factor. + +The higher the $K$-Factor, the more rapid changes in elo will occur. Basically, with a higher $K$-Factor, more elo will be gained per win, and more will be lost per loss. + +## Scored matches +Scored matches have two variables that determine how much the players' elo will change after the match, other than the $K$-Factor and the probability the winner had to win. +- How dominant the scoreline was ($D$) +- The Score Weight parameter ($W$) + +The greater the dominance factor $D$ is, the more elo will move around. A dominant victory will result in more elo being +given to the winner, and more being taken from the loser. + +And the greater the Score Weight parameter $W$ is, the more $D$ is taken into account when calculating elo, and the less elo +will move around in general. Increasing W will result in only more dominant matches being given a large elo change, and closer +matches only moving a small amount of elo. This overall reduction in elo changes can be counteracted by increasing $K$, which will +result in closer matches being given a moderate elo change, and dominant matches moving a lot of elo. + +The amount of elo to be lost or gained based on these factors can be written as the equation: + +$S_1=((E_LD)e^{-WE_W}+E_W)$ + +$S_2=E_L-(E_LD)e^{-WE_W}$ + +$Elo_W=K(S_1-E_W)$ + +$Elo_L=K(S_2-E_L)$ + +Where: +- $E_W$ is the expected win chance of the winner, +- $E_L$ is the expected win chance of the loser, +- $D$ is calculated as $Score_W/{(Score_W+Score_L)}$ +- $Elo_W$ is the amount of elo gained by the winner, and +- $Elo_L$ is the amount of elo gained by the loser (a negative value). + +The $Elo$ values are then added to the players' current elo. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..81d8f8d --- /dev/null +++ b/README.md @@ -0,0 +1,116 @@ +# go-elo: A flexible elo calculator for Go + +This package allows you to add flexible elo/skill tracking to your application. + +Go-elo supports unscored (win/loss) and scored matches, with options to tailor the elo curve to your liking. + +## Usage + +Install: + +```bash +go get github.com/gabehf/go-elo +``` + +Simple unscored example: + +```go +func main() { + c := elo.NewCalculator().Build() + + X, Y := c.Calculate(1200.0, 1100.0, &elo.MatchResult{ + Outcome: elo.OutcomePlayerOneWin, + }) + // X and Y are player one and player two's new elo values, respectively. +} +``` + +Simple scored example: + +```go +func main() { + c := elo.NewCalculator(). + WithStrategy(elo.StrategyScored). + Build() + + X, Y := c.Calculate(1200.0, 1100.0, &elo.MatchResult{ + PlayerOneScore: 12, + PlayerTwoScore: 9, + }) + // X and Y are player one and player two's new elo values, respectively. +} +``` + +Using the match system: + +```go +func main() { + c := elo.NewCalculatorBuilder().Build() + + p1 := MyPlayer{Elo: 1200} + p2 := MyPlayer{Elo: 1100} + + m := c.NewMatch(p1, p2) + + m.GetOdds() // returns player one and two's probability of winning + m.PlayerOneGain() // returns how much elo player one stands to gain + m.PlayerTwoGain() // returns how much elo player two stands to gain + m.SetStrategy(func(input *CalculatorInput) (r1 float64, r2 float64) { + // custom elo calculation method + // used for only this match + }) + + m.Play(&elo.MatchResult{ + Outcome: elo.OutcomePlayerTwoWin, + }) + + p1.GetElo() // player one's new elo + p2.GetElo() // player two's new elo +} +``` + +## Adjusting Parameters + +Go-elo has parameters that you can customize in order to fine tune the elo curve you are looking for. To learn about exactly how each of the parameters are used during the elo calculation, refer to the [ELO.md file](ELO.md) in this repo. + +### At the calculator level + +You can adjust parameters at the calculator level, when you want to reuse the settings over many matches. This is accomplished using methods with the calculator buildier. + +```go +func main() { + c := elo.NewCalculatorBuilder(). + WithKValue(32). // specify the K-Factor + WithScoreWeight(0.5). // specify the score weight + WithDeviation(200). // specify the deviation + WithStrategy(elo.StrategyScored). + WithIgnoreDraws(). // specify not to adjust elo after a draw + Build() + + // now you can use your adjusted elo calculator + m := c.NewMatch(...) +} +``` + +### At the match level + +You can also adjust the parameters at the per-match level. Here is an example: + +```go +func main() { + c := elo.newCalculatorBuilder().Build() + + p1 := MyPlayer{Elo: 1500} + p2 := MyPlayer{Elo: 1600} + + m := elo.NewMatch(p1, p2) + + // adjust parameters + m.SetKValue(54) + m.SetScoreWeight(0.33) + m.SetDeviation(250) + m.IgnoreDraws(true) + + m.Play(...) +} +``` diff --git a/calculator.go b/calculator.go new file mode 100644 index 0000000..b9e8b88 --- /dev/null +++ b/calculator.go @@ -0,0 +1,114 @@ +package elo + +type CalculatorBuilder struct { + c Calculator +} + +type Calculator struct { + k float64 + deviation float64 + scoreWeight float64 + ignoreDraws bool + strategy StrategyFunc +} + +func NewCalculatorBuilder() *CalculatorBuilder { + return &CalculatorBuilder{ + c: Calculator{ + k: 32, + deviation: 400, + strategy: StrategyDefault, + }} +} + +// Set a strategy for calculating elo. Default is elo.StrategyDefault. +func (b *CalculatorBuilder) WithStrategy(sf StrategyFunc) *CalculatorBuilder { + b.c.strategy = sf + return b +} + +// Set a K-Value. +// A greater K-Value means more rapid changes. Default is 32. +func (b *CalculatorBuilder) WithKValue(k float64) *CalculatorBuilder { + b.c.k = k + return b +} + +// Set a deviation. The lower the number, the greater the probabilty that +// the higher-rated player wins (and therefore less elo gained). Default is 400. +func (b *CalculatorBuilder) WithDeviation(d float64) *CalculatorBuilder { + b.c.deviation = d + return b +} + +// Set a score weight. The higher the number, the more the final score will influence +// the calculated elo ratings after the match. Must be greater than 0. Providing a negative +// value will result in no change to the score weight. Recommended values are between 0 and 1. +// Default is 0. +func (b *CalculatorBuilder) WithScoreWeight(w float64) *CalculatorBuilder { + if w < 0 || w > 1 { + return b + } + b.c.scoreWeight = w + return b +} + +// Set a deviation. The lower the number, the greater the probabilty that +// the higher-rated player wins (and therefore less elo gained). Default is 400. +func (b *CalculatorBuilder) WithIgnoreDraws() *CalculatorBuilder { + b.c.ignoreDraws = true + return b +} + +// Returns a Calculator reference using the settings defined by the builder. +func (b *CalculatorBuilder) Build() *Calculator { + return &b.c +} + +// Calculate elo changes using the calculator. Returns player one and player two's new +// elo values respectively. +func (c *Calculator) Calculate(p1, p2 float64, result *MatchResult) (float64, float64) { + if (result.Outcome == OutcomeDraw) && + c.ignoreDraws && + (result.PlayerOneScore == result.PlayerTwoScore) { + return p1, p2 + } + return c.strategy(&CalculatorInput{ + PlayerOne: p1, + PlayerTwo: p2, + PlayerOneScore: result.PlayerOneScore, + PlayerTwoScore: result.PlayerTwoScore, + Outcome: result.Outcome, + K: c.k, + Deviation: c.deviation, + ScoreWeight: c.scoreWeight, + }) +} + +type CalculatorInput struct { + + // Required. Elo of Player 1. + PlayerOne float64 + + // Required. Elo of Player 2. + PlayerTwo float64 + + // Required for non-scored strategies i.e. StrategyDefault. + Outcome MatchOutcome + + // Required for scored strategies i.e. StrategyScoredDefault or StrategyScoredKValue. + PlayerOneScore int + + // Required for scored strategies i.e. StrategyScoredDefault or StrategyScoredKValue. + PlayerTwoScore int + + // Required. K-Value for calculating elo changes. + // A greater K value means more rapid changes. + K float64 + + // Required. Deviation, as provided by the Calculator. + Deviation float64 + + // Required for scored strategies. + ScoreWeight float64 +} diff --git a/calculator_test.go b/calculator_test.go new file mode 100644 index 0000000..f9a6f4c --- /dev/null +++ b/calculator_test.go @@ -0,0 +1,82 @@ +package elo_test + +import ( + "math" + "testing" + + "github.com/gabehf/go-elo" +) + +func TestCalculate(t *testing.T) { + c := elo.NewCalculatorBuilder().Build() + + p1, p2 := 1200.0, 1000.0 + + n1, n2 := c.Calculate(p1, p2, &elo.MatchResult{ + Outcome: elo.OutcomePlayerOneWin, + }) + + if !almostEqual(math.Abs(1200-n1), math.Abs(1000-n2)) { + t.Fail() + t.Log("Elo gained and lost must be equal") + } + + if !almostEqual(n1, 1207.688098) { + t.Fail() + t.Logf("Expected P1 Elo %f, got %f\n", 1207.688098, n1) + } + if !almostEqual(n2, 992.311902) { + t.Fail() + t.Logf("Expected P2 Elo %f, got %f\n", 992.311902, n2) + } +} + +func TestCalculateScored(t *testing.T) { + c := elo.NewCalculatorBuilder(). + WithStrategy(elo.StrategyScored). + WithScoreWeight(0.33). + WithScoreWeight(-2). // will be ignored + Build() + + p1, p2 := 1200.0, 1000.0 + + n1, n2 := c.Calculate(p1, p2, &elo.MatchResult{ + PlayerOneScore: 12, + PlayerTwoScore: 8, + }) + + if !almostEqual(math.Abs(1200-n1), math.Abs(1000-n2)) { + t.Fail() + t.Log("Elo gained and lost must be equal") + } + + if !almostEqual(n1, 1203.589925) { + t.Fail() + t.Logf("Expected P1 Elo %f, got %f\n", 1203.589925, n1) + } + if !almostEqual(n2, 996.410075) { + t.Fail() + t.Logf("Expected P2 Elo %f, got %f\n", 996.410075, n2) + } + + // test ignore draw + + c = elo.NewCalculatorBuilder(). + WithIgnoreDraws(). + Build() + + p1, p2 = 1200.0, 1000.0 + + n1, n2 = c.Calculate(p1, p2, &elo.MatchResult{ + Outcome: elo.OutcomeDraw, + }) + + if !almostEqual(n1, 1200) { + t.Fail() + t.Logf("Draw not ignored. Expected P1 Elo %f, got %f\n", 1200.0, n1) + } + if !almostEqual(n2, 1000) { + t.Fail() + t.Logf("Draw not ignored. Expected P2 Elo %f, got %f\n", 1000.0, n2) + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..849b1e2 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module github.com/gabehf/go-elo + +go 1.22.1 diff --git a/match.go b/match.go new file mode 100644 index 0000000..9ade5f6 --- /dev/null +++ b/match.go @@ -0,0 +1,188 @@ +package elo + +import ( + "math" +) + +type Player interface { + GetElo() float64 + SetElo(float64) +} + +type Match struct { + PlayerOne Player + PlayerTwo Player + finished bool + strategy StrategyFunc + k float64 + deviation float64 + scoreWeight float64 + ignoreDraws bool +} + +// Args p1 and p2 should be non-nil pointers. +func (c *Calculator) NewMatch(p1, p2 Player) *Match { + m := new(Match) + m.PlayerOne = p1 + m.PlayerTwo = p2 + m.strategy = c.strategy + m.k = c.k + m.deviation = c.deviation + m.scoreWeight = c.scoreWeight + m.ignoreDraws = c.ignoreDraws + return m +} + +// Contains each player's odds to win, as a number between 0-1, where +// 0 is a 0% chance to win, and 1 is a 100% chance to win. +type MatchOdds struct { + PlayerOneOdds float64 + PlayerTwoOdds float64 +} + +type MatchResult struct { + // Required when using a non-scored strategy. + // Ignored when using a scored strategy. + Outcome MatchOutcome + + // Required when using a scored strategy. + // Ignored when using a non-scored strategy. + PlayerOneScore int + // Required when using a scored strategy. + // Ignored when using a non-scored strategy. + PlayerTwoScore int +} + +type MatchOutcome int + +const ( + OutcomeDraw = MatchOutcome(0) + OutcomePlayerOneWin = MatchOutcome(1) + OutcomePlayerTwoWin = MatchOutcome(2) +) + +// Set a strategy to be used for this match only. +func (m *Match) SetStrategy(sf StrategyFunc) { + m.strategy = sf +} + +// K must be non-negative. If a negative value is provided, K will be unchanged. +func (m *Match) SetKValue(k float64) { + if k < 0 { + return + } + m.k = k +} +func (m *Match) GetKValue() float64 { + return m.k +} + +// Score weight must be non-negative. +// If a negative value is provided, ScoreWeight will be unchanged. +func (m *Match) SetScoreWeight(w float64) { + if w < 0 { + return + } + m.scoreWeight = w +} +func (m *Match) GetScoreWeight() float64 { + return m.scoreWeight +} + +// Deviation must be non-negative. +// If a negative value is provided, the deviation will be unchanged. +func (m *Match) SetDeviation(d float64) { + if d < 0 { + return + } + m.deviation = d +} +func (m *Match) GetDeviation() float64 { + return m.deviation +} +func (m *Match) IgnoreDraws(ignore bool) { + m.ignoreDraws = ignore +} +func (m *Match) GetIgnoreDraws() bool { + return m.ignoreDraws +} + +func (m Match) GetOdds() *MatchOdds { + R1 := math.Pow(10, m.PlayerOne.GetElo()/m.deviation) + R2 := math.Pow(10, m.PlayerTwo.GetElo()/m.deviation) + + E1 := R1 / (R1 + R2) + E2 := R2 / (R1 + R2) + return &MatchOdds{ + PlayerOneOdds: E1, + PlayerTwoOdds: E2, + } +} + +// Adjusts the Match's player's elo according to who won the match. +// Can only be called once. Any subsequent calls on the same match will result in no changes +// to the players' elo ratings. +// +// Note: Play() uses a reference to the Match's calculator to determine the new elos. If the +// calculator no longer exists, the function will panic. +func (m *Match) Play(result *MatchResult) { + if m.finished || + ((result.Outcome == OutcomeDraw) && + m.ignoreDraws && + (result.PlayerOneScore == result.PlayerTwoScore)) { + return + } + n1, n2 := m.strategy(&CalculatorInput{ + PlayerOne: m.PlayerOne.GetElo(), + PlayerTwo: m.PlayerTwo.GetElo(), + PlayerOneScore: result.PlayerOneScore, + PlayerTwoScore: result.PlayerTwoScore, + Outcome: result.Outcome, + Deviation: m.deviation, + ScoreWeight: m.scoreWeight, + K: m.k, + }) + // fmt.Printf("%+v", CalculatorInput{ + // PlayerOne: m.PlayerOne.GetElo(), + // PlayerTwo: m.PlayerTwo.GetElo(), + // PlayerOneScore: result.PlayerOneScore, + // PlayerTwoScore: result.PlayerTwoScore, + // Outcome: result.Outcome, + // Deviation: m.deviation, + // ScoreWeight: m.scoreWeight, + // K: m.k, + // }) + m.PlayerOne.SetElo(n1) + m.PlayerTwo.SetElo(n2) + m.finished = true +} + +// Returns how much player one stands to gain if they win. +// Equivalent to how much player two will lose if they lose. +// +// Note: May not be accurate when using a scored strategy. +func (m Match) PlayerOneGain() float64 { + n1, _ := m.strategy(&CalculatorInput{ + PlayerOne: m.PlayerOne.GetElo(), + PlayerTwo: m.PlayerTwo.GetElo(), + Outcome: OutcomePlayerOneWin, + K: m.k, + Deviation: m.deviation, + }) + return n1 - m.PlayerOne.GetElo() +} + +// Returns how much player two stands to gain if they win. +// Equivalent to how much player one will lose if they lose. +// +// Note: May not be accurate when using a scored strategy. +func (m Match) PlayerTwoGain() float64 { + _, n2 := m.strategy(&CalculatorInput{ + PlayerOne: m.PlayerOne.GetElo(), + PlayerTwo: m.PlayerTwo.GetElo(), + Outcome: OutcomePlayerTwoWin, + K: m.k, + Deviation: m.deviation, + }) + return n2 - m.PlayerTwo.GetElo() +} diff --git a/match_test.go b/match_test.go new file mode 100644 index 0000000..026a674 --- /dev/null +++ b/match_test.go @@ -0,0 +1,173 @@ +package elo_test + +import ( + "testing" + + "github.com/gabehf/go-elo" +) + +func TestNewMatch(t *testing.T) { + c := elo.NewCalculatorBuilder().Build() + + p1 := new(player) + p1.elo = 1600 + p2 := new(player) + p2.elo = 1800 + + m := c.NewMatch(p1, p2) + + if m == nil { + t.Fail() + t.Log("Expected non-nil match.") + } +} + +func TestOverrides(t *testing.T) { + c := elo.NewCalculatorBuilder().Build() + + p1 := new(player) + p1.elo = 1600 + p2 := new(player) + p2.elo = 1800 + + m := c.NewMatch(p1, p2) + m.SetStrategy(func(input *elo.CalculatorInput) (r1 float64, r2 float64) { + return 1, 2 + }) + + m.Play(&elo.MatchResult{}) + + if !almostEqual(p1.elo, 1.0) || !almostEqual(p2.elo, 2.0) { + t.Fail() + t.Logf("Overriden strategy failed.") + } + + c = elo.NewCalculatorBuilder().WithStrategy(elo.StrategyScored).Build() + + p1.elo = 1600 + p2.elo = 1800 + m = c.NewMatch(p1, p2) + m.SetKValue(47) + m.SetDeviation(200) + m.SetScoreWeight(0.5) + m.IgnoreDraws(true) + + // bad overrides + m.SetKValue(-50) + m.SetDeviation(-200) + m.SetScoreWeight(-0.13) + + if !almostEqual(m.GetKValue(), 47.0) { + t.Fail() + t.Logf("Failed to override K value.") + } + if !almostEqual(m.GetScoreWeight(), 0.5) { + t.Fail() + t.Logf("Failed to override score weight.") + } + if !almostEqual(m.GetDeviation(), 200.0) { + t.Fail() + t.Logf("Failed to override deviation.") + } + if !m.GetIgnoreDraws() { + t.Fail() + t.Logf("Failed to override ignore draws.") + } + + m.Play(&elo.MatchResult{ + PlayerOneScore: 6, + PlayerTwoScore: 3, + }) + + if !almostEqual(p1.elo, 1627.219068) { + t.Fail() + t.Logf("Expected P1 Elo %f, got %f\n", 1627.219068, p1.elo) + } + if !almostEqual(p2.elo, 1772.780932) { + t.Fail() + t.Logf("Expected P2 Elo %f, got %f\n", 1772.780932, p2.elo) + } + +} + +func TestGetOdds(t *testing.T) { + c := elo.NewCalculatorBuilder().Build() + + p1 := new(player) + p1.elo = 1600 + p2 := new(player) + p2.elo = 1800 + + m := c.NewMatch(p1, p2) + o := m.GetOdds() + + if !almostEqual(o.PlayerOneOdds, 0.240253) { + t.Fail() + t.Logf("Expected P1 Odds to be %f, got %f\n", 0.240253, o.PlayerOneOdds) + } + if !almostEqual(o.PlayerTwoOdds, 0.759747) { + t.Fail() + t.Logf("Expected P2 Odds to be %f, got %f\n", 0.759747, o.PlayerTwoOdds) + } +} + +func TestGain(t *testing.T) { + c := elo.NewCalculatorBuilder().Build() + + p1 := new(player) + p1.elo = 1600 + p2 := new(player) + p2.elo = 1800 + + m := c.NewMatch(p1, p2) + g1 := m.PlayerOneGain() + g2 := m.PlayerTwoGain() + + if g1 < g2 { + t.Fail() + t.Log("Lower elo player's gains must be higher than the higher elo player.") + } + if g1 < 0 { + t.Fail() + t.Log("Elo gain must be above 0.") + } + if g2 < 0 { + t.Fail() + t.Log("Elo gain must be above 0.") + } + + if !almostEqual(g1, 24.311902) { + t.Fail() + t.Logf("Expected P1 Gain to be %f, got %f\n", 24.311902, g1) + } + if !almostEqual(g2, 7.688098) { + t.Fail() + t.Logf("Expected P2 Gain to be %f, got %f\n", 7.688098, g2) + } +} + +func TestFinishedMatch(t *testing.T) { + c := elo.NewCalculatorBuilder().Build() + + p1 := new(player) + p1.elo = 1600 + p2 := new(player) + p2.elo = 1800 + + m := c.NewMatch(p1, p2) + m.Play(&elo.MatchResult{ + Outcome: elo.OutcomePlayerOneWin, + }) + + o1 := p1.elo + o2 := p2.elo + + m.Play(&elo.MatchResult{ + Outcome: elo.OutcomePlayerOneWin, + }) + + if o1 != p1.elo || o2 != p2.elo { + t.Fail() + t.Log("The same matched played twice should not alter elo twice.") + } +} diff --git a/strategy.go b/strategy.go new file mode 100644 index 0000000..2de8121 --- /dev/null +++ b/strategy.go @@ -0,0 +1,95 @@ +package elo + +import ( + "math" +) + +type StrategyFunc func(input *CalculatorInput) (r1 float64, r2 float64) + +// Calculates elo based on a Win/Loss system. +func StrategyDefault(input *CalculatorInput) (float64, float64) { + + R1 := math.Pow(10, input.PlayerOne/input.Deviation) + R2 := math.Pow(10, input.PlayerTwo/input.Deviation) + + E1 := R1 / (R1 + R2) + E2 := R2 / (R1 + R2) + + var S1, S2 float64 + switch input.Outcome { + case OutcomePlayerOneWin: + S1 = 1.0 + S2 = 0.0 + case OutcomePlayerTwoWin: + S1 = 0.0 + S2 = 1.0 + case OutcomeDraw: + S1 = 0.5 + S2 = 0.5 + } + + NewP1 := input.PlayerOne + input.K*(S1-E1) + NewP2 := input.PlayerTwo + input.K*(S2-E2) + return NewP1, NewP2 +} + +func determineWeightedSValues(winnerScore, loserScore, winnerE, loserE, weight float64) (S1, S2 float64) { + // determine the domination factor + D := winnerScore / (winnerScore + loserScore) + // calculate the amount of elo to be gained or lost, based on the domination factor and the expected chance + // for the winner of the match to win + // in general, G is smaller if the winner was heavily favored, and larger if the winner was not favored + G := (1.0 - winnerE) * D + // apply the score weight multiplier + G = G * math.Exp((-1*weight)*winnerE) + // add the weight to the expected chance to win to get a value between winnerE and 1.0 that + // is weighted based on the domination factor, and will be multiplied by K to get actual elo gained + S1 = G + winnerE + // and subtract G from E2 to get a value between 0 and loserE that will be multiplied by K to get actual elo lost + S2 = loserE - G + + return S1, S2 +} + +// Calculates elo weighted by the final score. +// A more dominant score means greater elo gained. +func StrategyScored(input *CalculatorInput) (float64, float64) { + + R1 := math.Pow(10, input.PlayerOne/input.Deviation) + R2 := math.Pow(10, input.PlayerTwo/input.Deviation) + + E1 := R1 / (R1 + R2) + E2 := R2 / (R1 + R2) + + var S1, S2 float64 + if input.PlayerOneScore == 0 { + S1 = 0 + S2 = 1 + } else if input.PlayerTwoScore == 0 { + S1 = 1 + S2 = 0 + } else if input.PlayerOneScore > input.PlayerTwoScore { // P1 win + S1, S2 = determineWeightedSValues( + float64(input.PlayerOneScore), + float64(input.PlayerTwoScore), + E1, + E2, + input.ScoreWeight, + ) + } else if input.PlayerOneScore < input.PlayerTwoScore { // P2 win + S2, S1 = determineWeightedSValues( + float64(input.PlayerTwoScore), + float64(input.PlayerOneScore), + E2, + E1, + input.ScoreWeight, + ) + } else { + S1 = 0.5 + S2 = 0.5 + } + + NewP1 := input.PlayerOne + input.K*(S1-E1) + NewP2 := input.PlayerTwo + input.K*(S2-E2) + return NewP1, NewP2 +} diff --git a/strategy_test.go b/strategy_test.go new file mode 100644 index 0000000..4ce76cf --- /dev/null +++ b/strategy_test.go @@ -0,0 +1,396 @@ +package elo_test + +import ( + "math" + "testing" + + "github.com/gabehf/go-elo" +) + +// Equal precision to Go's default precision when printing with %f. +const float64EqualityThreshold = 1e-6 + +func almostEqual(a, b float64) bool { + return math.Abs(a-b) <= float64EqualityThreshold +} + +type player struct { + elo float64 +} + +func (p *player) GetElo() float64 { + return p.elo +} +func (p *player) SetElo(e float64) { + p.elo = e +} + +func TestDefaultCalculator(t *testing.T) { + c := elo.NewCalculatorBuilder().Build() + + p1 := new(player) + p1.elo = 1600 + p2 := new(player) + p2.elo = 1800 + + m := c.NewMatch(p1, p2) + m.SetStrategy(elo.StrategyDefault) + m.Play(&elo.MatchResult{ + Outcome: elo.OutcomePlayerTwoWin, + }) + + if !almostEqual(p1.elo, 1592.311901653) { + t.Fail() + t.Logf("Expected P1 Elo %f, got %f\n", 1592.311901653, p1.elo) + } + if !almostEqual(p2.elo, 1807.688098347) { + t.Fail() + t.Logf("Expected P2 Elo %f, got %f\n", 1807.688098347, p2.elo) + } + + p1.elo = 800 + p2.elo = 1300 + + m = c.NewMatch(p1, p2) + m.Play(&elo.MatchResult{ + Outcome: elo.OutcomePlayerOneWin, + }) + + if !almostEqual(p1.elo, 830.296313114) { + t.Fail() + t.Logf("Expected P1 Elo %f, got %f\n", 830.296313114, p1.elo) + } + if !almostEqual(p2.elo, 1269.703686886) { + t.Fail() + t.Logf("Expected P2 Elo %f, got %f\n", 1269.703686886, p2.elo) + } +} + +func TestScoredStrategy(t *testing.T) { + c := elo.NewCalculatorBuilder(). + WithStrategy(elo.StrategyScored). + Build() + + p1 := new(player) + p2 := new(player) + p1.elo = 1600 + p2.elo = 1800 + + m := c.NewMatch(p1, p2) + m.Play(&elo.MatchResult{ + PlayerOneScore: 150, + PlayerTwoScore: 160, + }) + + if !almostEqual(p1.elo, 1596.031949) { + t.Fail() + t.Logf("Expected P1 Elo %f, got %f\n", 1596.031949, p1.elo) + } + if !almostEqual(p2.elo, 1803.968051) { + t.Fail() + t.Logf("Expected P2 Elo %f, got %f\n", 1803.968051, p2.elo) + } + + p1.elo = 800 + p2.elo = 1300 + + m = c.NewMatch(p1, p2) + m.Play(&elo.MatchResult{ + PlayerOneScore: 15, + PlayerTwoScore: 20, + }) + + if !almostEqual(p1.elo, 799.026465) { + t.Fail() + t.Logf("Expected P1 Elo %f, got %f\n", 799.026465, p1.elo) + } + if !almostEqual(p2.elo, 1300.973535) { + t.Fail() + t.Logf("Expected P2 Elo %f, got %f\n", 1300.973535, p2.elo) + } + + p1.elo = 900 + p2.elo = 700 + + m = c.NewMatch(p1, p2) + m.Play(&elo.MatchResult{ + PlayerOneScore: 6, + PlayerTwoScore: 0, + }) + + if !almostEqual(p1.elo, 907.688098) { + t.Fail() + t.Logf("Expected P1 Elo %f, got %f\n", 907.688098, p1.elo) + } + if !almostEqual(p2.elo, 692.311902) { + t.Fail() + t.Logf("Expected P2 Elo %f, got %f\n", 692.311902, p2.elo) + } +} + +func TestWithKValue(t *testing.T) { + c := elo.NewCalculatorBuilder().WithKValue(55).Build() + + p1 := new(player) + p1.elo = 1600 + p2 := new(player) + p2.elo = 1800 + + m := c.NewMatch(p1, p2) + m.Play(&elo.MatchResult{ + Outcome: elo.OutcomePlayerTwoWin, + }) + + if !almostEqual(p1.elo, 1586.786081) { + t.Fail() + t.Logf("Expected P1 Elo %f, got %f\n", 1586.786081, p1.elo) + } + if !almostEqual(p2.elo, 1813.213919) { + t.Fail() + t.Logf("Expected P2 Elo %f, got %f\n", 1813.213919, p2.elo) + } + + p1.elo = 800 + p2.elo = 1300 + + m = c.NewMatch(p1, p2) + m.Play(&elo.MatchResult{ + Outcome: elo.OutcomePlayerOneWin, + }) + + if !almostEqual(p1.elo, 852.071788) { + t.Fail() + t.Logf("Expected P1 Elo %f, got %f\n", 852.071788, p1.elo) + } + if !almostEqual(p2.elo, 1247.928212) { + t.Fail() + t.Logf("Expected P2 Elo %f, got %f\n", 1247.928212, p2.elo) + } +} + +func TestScoredWithKValue(t *testing.T) { + c := elo.NewCalculatorBuilder(). + WithStrategy(elo.StrategyScored). + WithKValue(60). + Build() + + p1 := new(player) + p2 := new(player) + p1.elo = 1600 + p2.elo = 1800 + + m := c.NewMatch(p1, p2) + m.Play(&elo.MatchResult{ + PlayerOneScore: 230, + PlayerTwoScore: 160, + }) + + if !almostEqual(p1.elo, 1626.883353) { + t.Fail() + t.Logf("Expected P1 Elo %f, got %f\n", 1626.883353, p1.elo) + } + if !almostEqual(p2.elo, 1773.116647) { + t.Fail() + t.Logf("Expected P2 Elo %f, got %f\n", 1773.116647, p2.elo) + } + + p1.elo = 800 + p2.elo = 1300 + + m = c.NewMatch(p1, p2) + m.Play(&elo.MatchResult{ + PlayerOneScore: 0, + PlayerTwoScore: 4, + }) + + if !almostEqual(p1.elo, 796.805587) { + t.Fail() + t.Logf("Expected P1 Elo %f, got %f\n", 796.805587, p1.elo) + } + if !almostEqual(p2.elo, 1303.194413) { + t.Fail() + t.Logf("Expected P2 Elo %f, got %f\n", 1303.194413, p2.elo) + } +} + +func TestScoredWithCloseScore(t *testing.T) { + c := elo.NewCalculatorBuilder(). + WithStrategy(elo.StrategyScored). + Build() + + p1 := new(player) + p2 := new(player) + p1.elo = 1200 + p2.elo = 1100 + + m := c.NewMatch(p1, p2) + m.Play(&elo.MatchResult{ + PlayerOneScore: 501, + PlayerTwoScore: 500, + }) + + if p1.elo < 1200 { + t.Fail() + t.Log("Winning player's elo must always be higher than starting elo.") + } + if p2.elo > 1100 { + t.Fail() + t.Log("Losing player's elo must always be lower than starting elo.") + } + + if !almostEqual(p1.elo, 1205.764713) { + t.Fail() + t.Logf("Expected P1 Elo %f, got %f\n", 1205.764713, p1.elo) + } + if !almostEqual(p2.elo, 1094.235287) { + t.Fail() + t.Logf("Expected P2 Elo %f, got %f\n", 1094.235287, p2.elo) + } +} + +func TestWithDeviation(t *testing.T) { + c := elo.NewCalculatorBuilder(). + Build() + + p1 := new(player) + p2 := new(player) + p1.elo = 1600 + p2.elo = 1800 + + m := c.NewMatch(p1, p2) + m.Play(&elo.MatchResult{ + Outcome: elo.OutcomePlayerTwoWin, + }) + + o1 := p1.elo + o2 := p2.elo + + c = elo.NewCalculatorBuilder(). + WithDeviation(200). + Build() + + p1.elo = 1600 + p2.elo = 1800 + + m = c.NewMatch(p1, p2) + m.Play(&elo.MatchResult{ + Outcome: elo.OutcomePlayerTwoWin, + }) + + if o1 > p1.elo || o2 < p2.elo { + t.Fail() + t.Log("") + t.Logf("Tighter deviation should not result in more change to elo."+ + "\nD=400 P1: %f, D=200 P1: %f\n"+ + "D=400 P2: %f, D=200 P2: %f", o1, p1.elo, o2, p2.elo) + } + if !almostEqual(p1.elo, 1597.090909) { + t.Fail() + t.Logf("Expected P1 Elo %f, got %f\n", 1597.090909, p1.elo) + } + if !almostEqual(p2.elo, 1802.909091) { + t.Fail() + t.Logf("Expected P2 Elo %f, got %f\n", 1802.909091, p2.elo) + } +} + +func TestDraw(t *testing.T) { + + // w/l + + c := elo.NewCalculatorBuilder(). + Build() + + p1 := new(player) + p2 := new(player) + p1.elo = 1600 + p2.elo = 1800 + + m := c.NewMatch(p1, p2) + m.Play(&elo.MatchResult{ + Outcome: elo.OutcomeDraw, + }) + if !almostEqual(p1.elo, 1608.311902) { + t.Fail() + t.Logf("Expected P1 Elo %f, got %f\n", 1608.311902, p1.elo) + } + if !almostEqual(p2.elo, 1791.688098) { + t.Fail() + t.Logf("Expected P2 Elo %f, got %f\n", 1791.688098, p2.elo) + } + + // scored + + c = elo.NewCalculatorBuilder(). + WithStrategy(elo.StrategyScored). + Build() + + p1.elo = 1600 + p2.elo = 1800 + + m = c.NewMatch(p1, p2) + m.Play(&elo.MatchResult{ + PlayerOneScore: 500, + PlayerTwoScore: 500, + }) + + if !almostEqual(p1.elo, 1608.311902) { + t.Fail() + t.Logf("Expected P1 Elo %f, got %f\n", 1608.311902, p1.elo) + } + if !almostEqual(p2.elo, 1791.688098) { + t.Fail() + t.Logf("Expected P2 Elo %f, got %f\n", 1791.688098, p2.elo) + } +} + +func TestIgnoreDraw(t *testing.T) { + + // w/l + c := elo.NewCalculatorBuilder(). + WithIgnoreDraws(). + Build() + + p1 := new(player) + p2 := new(player) + p1.elo = 1600 + p2.elo = 1800 + + m := c.NewMatch(p1, p2) + m.Play(&elo.MatchResult{ + Outcome: elo.OutcomeDraw, + }) + + if !almostEqual(p1.elo, 1600) { + t.Fail() + t.Logf("Expected P1 Elo %f, got %f\n", 1600.0, p1.elo) + } + if !almostEqual(p2.elo, 1800) { + t.Fail() + t.Logf("Expected P2 Elo %f, got %f\n", 1600.0, p2.elo) + } + + // scored + c = elo.NewCalculatorBuilder(). + WithIgnoreDraws(). + WithStrategy(elo.StrategyScored). + Build() + + p1.elo = 1600 + p2.elo = 1800 + + m = c.NewMatch(p1, p2) + m.Play(&elo.MatchResult{ + PlayerOneScore: 500, + PlayerTwoScore: 500, + }) + + if !almostEqual(p1.elo, 1600) { + t.Fail() + t.Logf("Expected P1 Elo %f, got %f\n", 1600.0, p1.elo) + } + if !almostEqual(p2.elo, 1800) { + t.Fail() + t.Logf("Expected P2 Elo %f, got %f\n", 1600.0, p2.elo) + } +}