From 94e637f9bde7e10cc3aaccb4a51fa00409527c33 Mon Sep 17 00:00:00 2001 From: avitex Date: Wed, 15 Nov 2017 20:32:39 +1100 Subject: [PATCH] Initial commit --- .gitignore | 22 +++++++ lib/glicko/game_result.ex | 41 +++++++++++++ lib/glicko/player.ex | 123 ++++++++++++++++++++++++++++++++++++++ mix.exs | 20 +++++++ mix.lock | 4 ++ test/game_result_test.exs | 22 +++++++ test/player_test.exs | 60 +++++++++++++++++++ test/test_helper.exs | 1 + 8 files changed, 293 insertions(+) create mode 100644 .gitignore create mode 100644 lib/glicko/game_result.ex create mode 100644 lib/glicko/player.ex create mode 100644 mix.exs create mode 100644 mix.lock create mode 100644 test/game_result_test.exs create mode 100644 test/player_test.exs create mode 100644 test/test_helper.exs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b33c497 --- /dev/null +++ b/.gitignore @@ -0,0 +1,22 @@ +# The directory Mix will write compiled artifacts to. +/_build/ + +# If you run "mix test --cover", coverage assets end up here. +/cover/ + +# The directory Mix downloads your dependencies sources to. +/deps/ + +# Where 3rd-party dependencies like ExDoc output generated docs. +/doc/ + +# Ignore .fetch files in case you like to edit your project deps locally. +/.fetch + +# If the VM crashes, it generates a dump, let's ignore it too. +erl_crash.dump + +# Also ignore archive artifacts (built via "mix archive.build"). +*.ez + +.editorconfig diff --git a/lib/glicko/game_result.ex b/lib/glicko/game_result.ex new file mode 100644 index 0000000..407b5f2 --- /dev/null +++ b/lib/glicko/game_result.ex @@ -0,0 +1,41 @@ +defmodule Glicko.GameResult do + @moduledoc """ + This module provides a representation of a game result against an opponent. + + ## Usage + + iex> opponent = Player.new_v2 + iex> GameResult.new(opponent, 0.0) + %GameResult{score: 0.0, opponent: %Player{version: :v2, rating: 0.0, rating_deviation: 2.014761872416068, volatility: 0.06}} + iex> GameResult.new(opponent, :win) # With shortcut + %GameResult{score: 1.0, opponent: %Player{version: :v2, rating: 0.0, rating_deviation: 2.014761872416068, volatility: 0.06}} + + """ + + alias Glicko.Player + + defstruct [ + :score, + :opponent, + ] + + @type t :: %__MODULE__{score: float, opponent: Player.t} + + @type result_type_t :: :loss | :draw | :win + + @result_type_map %{loss: 0.0, draw: 0.5, win: 1.0} + + @doc """ + Creates a new GameResult against an opponent. + + Supports passing either `:loss`, `:draw`, or `:win` as shortcuts. + """ + @spec new(opponent :: Player.t, result_type_t | float) :: t + def new(opponent, result_type) when is_atom(result_type) and result_type in [:loss, :draw, :win] do + new(opponent, Map.fetch!(@result_type_map, result_type)) + end + def new(opponent, score) when is_number(score), do: %__MODULE__{ + score: score, + opponent: opponent, + } +end diff --git a/lib/glicko/player.ex b/lib/glicko/player.ex new file mode 100644 index 0000000..4ef030b --- /dev/null +++ b/lib/glicko/player.ex @@ -0,0 +1,123 @@ +defmodule Glicko.Player do + @moduledoc """ + A convenience wrapper that handles conversions between glicko versions one and two. + + ## Usage + + Create a player with the default values for an unrated player. + + iex> Player.new_v2 + %Player{version: :v2, rating: 0.0, rating_deviation: 2.014761872416068, volatility: 0.06} + + Create a player with custom values. + + iex> Player.new_v2([rating: 1500, rating_deviation: 50, volatility: 0.05]) + %Player{version: :v2, rating: 1500, rating_deviation: 50, volatility: 0.05} + + Convert a *v2* player to a *v1*. Note this drops the volatility. + + iex> Player.new_v2 |> Player.to_v1 + %Player{version: :v1, rating: 1.5e3, rating_deviation: 350.0, volatility: nil} + + Convert a *v1* player to a *v2*. + + iex> Player.new_v1 |> Player.to_v2(0.06) + %Player{version: :v2, rating: 0.0, rating_deviation: 2.014761872416068, volatility: 0.06} + + Note calling `to_v1` with a *v1* player or likewise with `to_v2` and a *v2* player + will pass-through unchanged. The volatility arg in this case is ignored. + + iex> player_v2 = Player.new_v2 + iex> player_v2 == Player.to_v2(player_v2) + true + + """ + + @magic_version_scale 173.7178 + @magic_version_scale_rating 1500.0 + + @default_v1_rating 1500.0 + @default_v1_rating_deviation 350.0 + + @default_v2_volatility 0.06 + + @type t :: v1_t | v2_t + + @type v1_t :: %__MODULE__{version: :v1, rating: float, rating_deviation: float, volatility: nil} + @type v2_t :: %__MODULE__{version: :v2, rating: float, rating_deviation: float, volatility: float} + + defstruct [ + :version, + :rating, + :rating_deviation, + :volatility, + ] + + @doc """ + Creates a new v1 player. + + If not overriden, will use default values for an unrated player. + """ + @spec new_v1([rating: float, rating_deviation: float]) :: v1_t + def new_v1(opts \\ []), do: %__MODULE__{ + version: :v1, + rating: Keyword.get(opts, :rating, @default_v1_rating), + rating_deviation: Keyword.get(opts, :rating_deviation, @default_v1_rating_deviation), + volatility: nil, + } + + @doc """ + Creates a new v2 player. + + If not overriden, will use default values for an unrated player. + """ + @spec new_v2([rating: float, rating_deviation: float, volatility: float]) :: v2_t + def new_v2(opts \\ []), do: %__MODULE__{ + version: :v2, + rating: Keyword.get(opts, :rating, @default_v1_rating |> scale_rating_to(:v2)), + rating_deviation: Keyword.get(opts, :rating_deviation, @default_v1_rating_deviation |> scale_rating_deviation_to(:v2)), + volatility: Keyword.get(opts, :volatility, @default_v2_volatility), + } + + @doc """ + Converts a v2 player to a v1. + + A v1 player will pass-through unchanged. + + Note the volatility field used in a v2 player will be lost in the conversion. + """ + @spec to_v1(player :: t) :: v1_t + def to_v1(player = %__MODULE__{version: :v1}), do: player + def to_v1(player = %__MODULE__{version: :v2}), do: new_v1([ + rating: player.rating |> scale_rating_to(:v1), + rating_deviation: player.rating_deviation |> scale_rating_deviation_to(:v1), + ]) + + @doc """ + Converts a v1 player to a v2. + + A v2 player will pass-through unchanged with the volatility arg ignored. + """ + @spec to_v2(player :: t, volatility :: float) :: v2_t + def to_v2(player, volatility \\ @default_v2_volatility) + def to_v2(player = %__MODULE__{version: :v2}, _volatility), do: player + def to_v2(player = %__MODULE__{version: :v1}, volatility), do: new_v2([ + rating: player.rating |> scale_rating_to(:v2), + rating_deviation: player.rating_deviation |> scale_rating_deviation_to(:v2), + volatility: volatility, + ]) + + @doc """ + Scales a players rating. + """ + @spec scale_rating_to(rating :: float, to_version :: :v1 | :v2) :: float + def scale_rating_to(rating, :v1), do: (rating * @magic_version_scale) + @magic_version_scale_rating + def scale_rating_to(rating, :v2), do: (rating - @magic_version_scale_rating) / @magic_version_scale + + @doc """ + Scales a players rating deviation. + """ + @spec scale_rating_deviation_to(rating_deviation :: float, to_version :: :v1 | :v2) :: float + def scale_rating_deviation_to(rating_deviation, :v1), do: rating_deviation * @magic_version_scale + def scale_rating_deviation_to(rating_deviation, :v2), do: rating_deviation / @magic_version_scale +end diff --git a/mix.exs b/mix.exs new file mode 100644 index 0000000..4185f6a --- /dev/null +++ b/mix.exs @@ -0,0 +1,20 @@ +defmodule Glicko.Mixfile do + use Mix.Project + + def project, do: [ + app: :glicko, + version: "0.1.0", + elixir: "~> 1.5", + start_permanent: Mix.env == :prod, + deps: deps(), + ] + + def application, do: [ + extra_applications: [:logger], + ] + + defp deps, do: [ + {:ex_doc, "~> 0.16", only: :dev, runtime: false}, + {:credo, "~> 0.8", only: [:dev, :test], runtime: false}, + ] +end diff --git a/mix.lock b/mix.lock new file mode 100644 index 0000000..d746aaf --- /dev/null +++ b/mix.lock @@ -0,0 +1,4 @@ +%{"bunt": {:hex, :bunt, "0.2.0", "951c6e801e8b1d2cbe58ebbd3e616a869061ddadcc4863d0a2182541acae9a38", [], [], "hexpm"}, + "credo": {:hex, :credo, "0.8.8", "990e7844a8d06ebacd88744a55853a83b74270b8a8461c55a4d0334b8e1736c9", [], [{:bunt, "~> 0.2.0", [hex: :bunt, repo: "hexpm", optional: false]}], "hexpm"}, + "earmark": {:hex, :earmark, "1.2.3", "206eb2e2ac1a794aa5256f3982de7a76bf4579ff91cb28d0e17ea2c9491e46a4", [], [], "hexpm"}, + "ex_doc": {:hex, :ex_doc, "0.18.1", "37c69d2ef62f24928c1f4fdc7c724ea04aecfdf500c4329185f8e3649c915baf", [], [{:earmark, "~> 1.1", [hex: :earmark, repo: "hexpm", optional: false]}], "hexpm"}} diff --git a/test/game_result_test.exs b/test/game_result_test.exs new file mode 100644 index 0000000..cbb8080 --- /dev/null +++ b/test/game_result_test.exs @@ -0,0 +1,22 @@ +defmodule Glicko.GameResultTest do + use ExUnit.Case + + alias Glicko.{ + Player, + GameResult, + } + + doctest GameResult + + @opponent Player.new_v2 + + @valid_game_result %GameResult{opponent: @opponent, score: 0.0} + + test "create game result" do + assert @valid_game_result == GameResult.new(@opponent, 0.0) + end + + test "create game result with shortcut" do + assert @valid_game_result == GameResult.new(@opponent, :loss) + end +end diff --git a/test/player_test.exs b/test/player_test.exs new file mode 100644 index 0000000..03c3b21 --- /dev/null +++ b/test/player_test.exs @@ -0,0 +1,60 @@ +defmodule Glicko.PlayerTest do + use ExUnit.Case + + alias Glicko.Player + + doctest Player + + @valid_v1_base %Player{version: :v1, rating: 1.0, rating_deviation: 2.0, volatility: nil} + @valid_v2_base %Player{version: :v2, rating: 1.0, rating_deviation: 2.0, volatility: 3.0} + + test "create v1" do + assert @valid_v1_base == Player.new_v1([rating: 1.0, rating_deviation: 2.0]) + end + + test "create v2" do + assert @valid_v2_base == Player.new_v2([rating: 1.0, rating_deviation: 2.0, volatility: 3.0]) + end + + test "convert player v1 -> v2" do + assert %Player{ + version: :v2, + rating: Player.scale_rating_to(1.0, :v2), + rating_deviation: Player.scale_rating_deviation_to(2.0, :v2), + volatility: 3.0, + } == Player.to_v2(@valid_v1_base, 3.0) + end + + test "convert player v2 -> v1" do + assert %Player{ + version: :v1, + rating: Player.scale_rating_to(1.0, :v1), + rating_deviation: Player.scale_rating_deviation_to(2.0, :v1), + volatility: nil, + } == Player.to_v1(@valid_v2_base) + end + + test "convert player v1 -> v1" do + assert @valid_v1_base == Player.to_v1(@valid_v1_base) + end + + test "convert player v2 -> v2" do + assert @valid_v2_base == Player.to_v2(@valid_v2_base) + end + + test "scale rating v1 -> v2" do + assert_in_delta Player.scale_rating_to(1673.7178, :v2), 1.0, 0.1 + end + + test "scale rating v2 -> v1" do + assert_in_delta Player.scale_rating_to(1.0, :v1), 1673.7178, 0.1 + end + + test "scale rating deviation v1 -> v2" do + assert_in_delta Player.scale_rating_deviation_to(173.7178, :v2), 1.0, 0.1 + end + + test "scale rating deviation v2 -> v1" do + assert_in_delta Player.scale_rating_deviation_to(1.0, :v1), 173.7178, 0.1 + end +end diff --git a/test/test_helper.exs b/test/test_helper.exs new file mode 100644 index 0000000..869559e --- /dev/null +++ b/test/test_helper.exs @@ -0,0 +1 @@ +ExUnit.start()