From 38ceb96645f574efea66aa0fd6edb0a05f308711 Mon Sep 17 00:00:00 2001 From: jauggy Date: Fri, 13 Dec 2024 12:08:47 +1100 Subject: [PATCH] Add num_matches column Add num_matches column --- lib/teiserver/account/schemas/rating.ex | 4 +- lib/teiserver/game/libs/match_rating_lib.ex | 79 +++++++--- .../20241212224758_add_num_matches_column.exs | 24 +++ .../game/libs/match_rating_lib_test.exs | 137 ++++++++++++++++++ 4 files changed, 219 insertions(+), 25 deletions(-) create mode 100644 priv/repo/migrations/20241212224758_add_num_matches_column.exs create mode 100644 test/teiserver/game/libs/match_rating_lib_test.exs diff --git a/lib/teiserver/account/schemas/rating.ex b/lib/teiserver/account/schemas/rating.ex index 2d4ce2f01..6ade2fc50 100644 --- a/lib/teiserver/account/schemas/rating.ex +++ b/lib/teiserver/account/schemas/rating.ex @@ -13,6 +13,7 @@ defmodule Teiserver.Account.Rating do field :leaderboard_rating, :float field :last_updated, :utc_datetime + field :num_matches, :integer end @doc false @@ -20,8 +21,9 @@ defmodule Teiserver.Account.Rating do stats |> cast( attrs, - ~w(user_id rating_type_id rating_value skill uncertainty last_updated leaderboard_rating)a + ~w(user_id rating_type_id rating_value skill uncertainty last_updated leaderboard_rating num_matches)a ) + # fields below are required; num_matches is not required |> validate_required( ~w(user_id rating_type_id rating_value skill uncertainty last_updated leaderboard_rating)a ) diff --git a/lib/teiserver/game/libs/match_rating_lib.ex b/lib/teiserver/game/libs/match_rating_lib.ex index 258ceae35..3d00b1ab8 100644 --- a/lib/teiserver/game/libs/match_rating_lib.ex +++ b/lib/teiserver/game/libs/match_rating_lib.ex @@ -87,7 +87,7 @@ defmodule Teiserver.Game.MatchRatingLib do # If override is set to true we skip the next few checks override -> - do_rate_match(match) + do_rate_match(match, override?: true) not Enum.empty?(logs) -> {:error, :already_rated} @@ -100,13 +100,14 @@ defmodule Teiserver.Game.MatchRatingLib do end end - @spec do_rate_match(Teiserver.Battle.Match.t()) :: :ok + @spec do_rate_match(Teiserver.Battle.Match.t(), any()) :: :ok + defp do_rate_match(match, opts \\ []) + # The algorithm has not been implemented for FFA correctly so we have a clause for # 2 teams (correctly implemented) and a special for 3+ teams - defp do_rate_match(%{team_count: 2} = match) do + defp do_rate_match(%{team_count: 2} = match, opts) do rating_type_id = Game.get_or_add_rating_type(match.game_type) partied_rating_type_id = Game.get_or_add_rating_type("Partied Team") - # This allows us to handle partied players slightly differently # we looked at doing this but there was not enough data. I've # left the code commented out because it was such a pain to get @@ -205,7 +206,7 @@ defmodule Teiserver.Game.MatchRatingLib do rating_update = rate_result[user_id] user_rating = rating_lookup[user_id] || BalanceLib.default_rating(rating_type_id) - do_update_rating(user_id, match, user_rating, rating_update) + do_update_rating(user_id, match, user_rating, rating_update, opts) end) loss_ratings = @@ -213,12 +214,10 @@ defmodule Teiserver.Game.MatchRatingLib do |> Enum.map(fn %{user_id: user_id} -> rating_update = rate_result[user_id] user_rating = rating_lookup[user_id] || BalanceLib.default_rating(rating_type_id) - do_update_rating(user_id, match, user_rating, rating_update) + do_update_rating(user_id, match, user_rating, rating_update, opts) end) - Ecto.Multi.new() - |> Ecto.Multi.insert_all(:insert_all, Teiserver.Game.RatingLog, win_ratings ++ loss_ratings) - |> Teiserver.Repo.transaction() + save_rating_logs(match.id, win_ratings, loss_ratings, opts) # Update the match to track rating type {:ok, _} = Battle.update_match(match, %{rating_type_id: rating_type_id}) @@ -233,11 +232,10 @@ defmodule Teiserver.Game.MatchRatingLib do :ok end - defp do_rate_match(%{team_count: team_count} = match) do + defp do_rate_match(%{team_count: team_count} = match, opts) do # When there are more than 2 teams we update the rating as if it was a 2 team game # where if you won, the opponent was the best losing team # and if you lost the opponent was whoever won - rating_type_id = Game.get_or_add_rating_type(match.game_type) partied_rating_type_id = Game.get_or_add_rating_type("Partied Team") @@ -351,7 +349,7 @@ defmodule Teiserver.Game.MatchRatingLib do rating_update = win_result[user_id] user_rating = rating_lookup[user_id] || BalanceLib.default_rating(rating_type_id) - do_update_rating(user_id, match, user_rating, rating_update) + do_update_rating(user_id, match, user_rating, rating_update, opts) end) # If you lose you just count as losing against the winner @@ -366,7 +364,7 @@ defmodule Teiserver.Game.MatchRatingLib do user_rating = rating_lookup[user_id] || BalanceLib.default_rating(rating_type_id) ratiod_rating_update = apply_change_ratio(user_rating, rating_update, opponent_ratio) - do_update_rating(user_id, match, user_rating, ratiod_rating_update) + do_update_rating(user_id, match, user_rating, ratiod_rating_update, opts) end) end) |> List.flatten() @@ -409,9 +407,7 @@ defmodule Teiserver.Game.MatchRatingLib do # end) # |> List.flatten - Ecto.Multi.new() - |> Ecto.Multi.insert_all(:insert_all, Teiserver.Game.RatingLog, win_ratings ++ loss_ratings) - |> Teiserver.Repo.transaction() + save_rating_logs(match.id, win_ratings, loss_ratings, opts) # Update the match to track rating type {:ok, _} = Battle.update_match(match, %{rating_type_id: rating_type_id}) @@ -426,8 +422,6 @@ defmodule Teiserver.Game.MatchRatingLib do :ok end - defp do_rate_match(_), do: :ok - # Used to ratio the skill lost when there are more than 2 teams @spec apply_change_ratio(map(), {number(), number()}, number()) :: {number(), number()} defp apply_change_ratio(_user_rating, rating_update, 1.0), do: rating_update @@ -441,8 +435,9 @@ defmodule Teiserver.Game.MatchRatingLib do {new_skill, u} end - @spec do_update_rating(T.userid(), map(), map(), {number(), number()}) :: any - defp do_update_rating(user_id, match, user_rating, rating_update) do + @spec do_update_rating(T.userid(), map(), map(), {number(), number()}, any()) :: any + defp do_update_rating(user_id, match, user_rating, rating_update, opts) do + override? = Keyword.get(opts, :override?, false) # It's possible they don't yet have a rating user_rating = if Map.get(user_rating, :user_id) do @@ -452,13 +447,24 @@ defmodule Teiserver.Game.MatchRatingLib do Account.create_rating( Map.merge(user_rating, %{ user_id: user_id, - last_updated: match.finished + last_updated: match.finished, + num_matches: 0 }) ) rating end + new_num_matches = + cond do + # This is the player's first match + user_rating.num_matches == nil -> 1 + # We are re-rating a previously rated match, so num_matches unchanged + override? -> user_rating.num_matches + # Otherwise increment by one + true -> user_rating.num_matches + 1 + end + rating_type_id = user_rating.rating_type_id {new_skill, new_uncertainty} = rating_update new_rating_value = BalanceLib.calculate_rating_value(new_skill, new_uncertainty) @@ -469,7 +475,8 @@ defmodule Teiserver.Game.MatchRatingLib do skill: new_skill, uncertainty: new_uncertainty, leaderboard_rating: new_leaderboard_rating, - last_updated: match.finished + last_updated: match.finished, + num_matches: new_num_matches }) %{ @@ -611,7 +618,7 @@ defmodule Teiserver.Game.MatchRatingLib do end end - defp re_rate_specific_matches(ids) do + def re_rate_specific_matches(ids) do Battle.list_matches( search: [ id_in: ids @@ -619,7 +626,7 @@ defmodule Teiserver.Game.MatchRatingLib do limit: :infinity, preload: [:members] ) - |> Enum.map(fn match -> rate_match(match) end) + |> Enum.map(fn match -> rate_match(match, true) end) end @spec predict_winning_team([map()], non_neg_integer()) :: map() @@ -723,4 +730,28 @@ defmodule Teiserver.Game.MatchRatingLib do result end end + + # Saves ratings logs to database + # If override? then delete existing logs of that match before we insert + defp save_rating_logs(match_id, win_ratings, loss_ratings, opts) do + override? = Keyword.get(opts, :override?, false) + + if(override?) do + Ecto.Multi.new() + |> Ecto.Multi.run(:delete_existing, fn repo, _ -> + query = """ + delete from teiserver_game_rating_logs l where + l.match_id = $1 + """ + + Ecto.Adapters.SQL.query(repo, query, [match_id]) + end) + |> Ecto.Multi.insert_all(:insert_all, Teiserver.Game.RatingLog, win_ratings ++ loss_ratings) + |> Teiserver.Repo.transaction() + else + Ecto.Multi.new() + |> Ecto.Multi.insert_all(:insert_all, Teiserver.Game.RatingLog, win_ratings ++ loss_ratings) + |> Teiserver.Repo.transaction() + end + end end diff --git a/priv/repo/migrations/20241212224758_add_num_matches_column.exs b/priv/repo/migrations/20241212224758_add_num_matches_column.exs new file mode 100644 index 000000000..4c32997e8 --- /dev/null +++ b/priv/repo/migrations/20241212224758_add_num_matches_column.exs @@ -0,0 +1,24 @@ +defmodule Teiserver.Repo.Migrations.AddNumMatchesColumn do + use Ecto.Migration + + def change do + alter table("teiserver_account_ratings") do + add :num_matches, :integer + end + + # Populate num_matches column + up_query = """ + UPDATE teiserver_account_ratings SET num_matches = temp_table.count + FROM (SELECT user_id, rating_type_id , count(*) from teiserver_game_rating_logs tgrl + where match_id is not null + group by user_id , rating_type_id ) AS temp_table + WHERE teiserver_account_ratings.user_id = temp_table.user_id + and teiserver_account_ratings.rating_type_id = temp_table.rating_type_id + """ + + # If we rollback we don't have to do anything + rollback_query = "" + + execute(up_query, rollback_query) + end +end diff --git a/test/teiserver/game/libs/match_rating_lib_test.exs b/test/teiserver/game/libs/match_rating_lib_test.exs new file mode 100644 index 000000000..42542a225 --- /dev/null +++ b/test/teiserver/game/libs/match_rating_lib_test.exs @@ -0,0 +1,137 @@ +defmodule Teiserver.Game.MatchRatingLibTest do + @moduledoc false + use Teiserver.DataCase, async: true + alias Teiserver.Game.MatchRatingLib + alias Teiserver.Account.AccountTestLib + alias Teiserver.Battle.MatchLib + alias Teiserver.Account + alias Teiserver.Battle + alias Teiserver.Game + + test "num_matches is updated after rating a match" do + # Create two user + user1 = AccountTestLib.user_fixture() + user2 = AccountTestLib.user_fixture() + + match = create_fake_match(user1.id, user2.id) + + # Check ratings of users before we rate the match + rating_type_id = Game.get_or_add_rating_type(match.game_type) + + ratings = + Account.list_ratings( + search: [ + rating_type_id: rating_type_id, + user_id_in: [user1.id, user2.id] + ] + ) + |> Map.new(fn rating -> + {rating.user_id, rating} + end) + + assert ratings[user1.id] == nil + assert ratings[user2.id] == nil + + MatchRatingLib.rate_match(match.id) + + # Check ratings of users after match + ratings = get_ratings([user1.id, user2.id], rating_type_id) + + assert ratings[user1.id].skill == 27.637760127073694 + assert ratings[user2.id].skill == 22.362239872926306 + + assert ratings[user1.id].num_matches == 1 + assert ratings[user1.id].num_matches == 1 + + # Create another match + match = create_fake_match(user1.id, user2.id) + MatchRatingLib.rate_match(match.id) + + # Check ratings of users after match + ratings = get_ratings([user1.id, user2.id], rating_type_id) + + assert ratings[user1.id].skill == 29.662576313923775 + assert ratings[user2.id].skill == 20.337423686076225 + + # Check num_matches has increased + assert ratings[user1.id].num_matches == 2 + assert ratings[user1.id].num_matches == 2 + end + + defp get_ratings(userids, rating_type_id) do + Account.list_ratings( + search: [ + rating_type_id: rating_type_id, + user_id_in: userids + ] + ) + |> Map.new(fn rating -> + {rating.user_id, rating} + end) + end + + defp create_fake_match(user1_id, user2_id) do + team_count = 2 + team_size = 1 + game_type = MatchLib.game_type(team_size, team_count) + server_uuid = UUID.uuid1() + end_time = Timex.now() + + start_time = DateTime.add(end_time, 50, :minute) + + # Create a match + {:ok, match} = + Battle.create_match(%{ + server_uuid: server_uuid, + uuid: UUID.uuid1(), + map: "Koom valley", + data: %{}, + tags: %{}, + winning_team: 0, + team_count: team_count, + team_size: team_size, + passworded: false, + processed: true, + game_type: game_type, + # All rooms are hosted by the same user for now + founder_id: 1, + bots: %{}, + queue_id: nil, + started: start_time, + finished: end_time + }) + + # Create match memberships + memberships1 = [ + %{ + team_id: 0, + win: match.winning_team == 0, + stats: %{}, + party_id: nil, + user_id: user1_id, + match_id: match.id + } + ] + + memberships2 = [ + %{ + team_id: 1, + win: match.winning_team == 1, + stats: %{}, + party_id: nil, + user_id: user2_id, + match_id: match.id + } + ] + + Ecto.Multi.new() + |> Ecto.Multi.insert_all( + :insert_all, + Battle.MatchMembership, + memberships1 ++ memberships2 + ) + |> Teiserver.Repo.transaction() + + match + end +end