diff --git a/lib/teiserver/battle/tasks/seasonal_uncertainty_reset_task.ex b/lib/teiserver/battle/tasks/seasonal_uncertainty_reset_task.ex deleted file mode 100644 index 00ccb0add..000000000 --- a/lib/teiserver/battle/tasks/seasonal_uncertainty_reset_task.ex +++ /dev/null @@ -1,62 +0,0 @@ -defmodule Teiserver.Battle.SeasonalUncertaintyResetTask do - alias Teiserver.{Account, Game} - alias Teiserver.Battle.BalanceLib - require Logger - - @spec perform() :: :ok - def perform() do - start_time = System.system_time(:millisecond) - - new_last_updated = Timex.now() - {_skill, new_uncertainty} = Openskill.rating() - - ratings_count = - Account.list_ratings(limit: :infinity) - |> Enum.map(fn rating -> - reset_rating(rating, new_uncertainty, new_last_updated) - 1 - end) - |> Enum.count() - - time_taken = System.system_time(:millisecond) - start_time - - Logger.info( - "SeasonalUncertaintyResetTask complete, took #{time_taken}ms to reset #{ratings_count} ratings" - ) - end - - defp reset_rating(existing, _new_uncertainty, new_last_updated) do - # Use the greater of the existing uncertainty or the minimum value (5.0) - new_uncertainty = max(existing.uncertainty, 5.0) - - new_rating_value = BalanceLib.calculate_rating_value(existing.skill, new_uncertainty) - - new_leaderboard_rating = - BalanceLib.calculate_leaderboard_rating(existing.skill, new_uncertainty) - - Account.update_rating(existing, %{ - rating_value: new_rating_value, - uncertainty: new_uncertainty, - leaderboard_rating: new_leaderboard_rating, - last_updated: new_last_updated - }) - - log_params = %{ - user_id: existing.user_id, - rating_type_id: existing.rating_type_id, - match_id: nil, - inserted_at: new_last_updated, - value: %{ - reason: "Seasonal reset", - rating_value: new_rating_value, - skill: existing.skill, - uncertainty: new_uncertainty, - rating_value_change: new_rating_value - existing.rating_value, - skill_change: 0, - uncertainty_change: new_uncertainty - existing.uncertainty - } - } - - {:ok, _} = Game.create_rating_log(log_params) - end -end diff --git a/lib/teiserver/mix_tasks/seasonal_uncertainty_reset_task.ex b/lib/teiserver/mix_tasks/seasonal_uncertainty_reset_task.ex new file mode 100644 index 000000000..7a80762a1 --- /dev/null +++ b/lib/teiserver/mix_tasks/seasonal_uncertainty_reset_task.ex @@ -0,0 +1,99 @@ +defmodule Mix.Tasks.Teiserver.SeasonalUncertaintyResetTask do + @moduledoc """ + Run with + mix teiserver.seasonal_uncertainty_reset_task + If you want to specify the uncertainty target use + mix teiserver.seasonal_uncertainty_reset_task 5 + where 5 is the uncertainty target + """ + + use Mix.Task + + require Logger + + @spec run(list()) :: :ok + def run(args) do + Logger.info("Args: #{args}") + default_uncertainty_target = "5" + {uncertainty_target, _} = Enum.at(args, 0, default_uncertainty_target) |> Float.parse() + + Application.ensure_all_started(:teiserver) + + start_time = System.system_time(:millisecond) + + sql_transaction_result = + Ecto.Multi.new() + |> Ecto.Multi.run(:create_temp_table, fn repo, _ -> + query = """ + CREATE temp table temp_table as + SELECT + *, + greatest(0, skill - new_uncertainty) as new_rating, + new_uncertainty - uncertainty as uncertainty_change, + greatest(0, skill - new_uncertainty)- rating_value as rating_value_change, + skill - 3 * new_uncertainty as new_leaderboard_rating + FROM + ( + SELECT + user_id, + rating_type_id, + rating_value, + uncertainty, + calculate_season_uncertainty(uncertainty, last_updated, $1) as new_uncertainty, + skill + FROM + teiserver_account_ratings tar + ); + """ + + Ecto.Adapters.SQL.query(repo, query, [uncertainty_target]) + end) + |> Ecto.Multi.run(:add_logs, fn repo, _ -> + query = """ + INSERT INTO teiserver_game_rating_logs (inserted_at, rating_type_id, user_id, value) + SELECT + now(), + rating_type_id, + user_id, + JSON_BUILD_OBJECT( + 'skill', skill, + 'reason', 'Uncertainty minimum override', + 'uncertainty', new_uncertainty, + 'rating_value', new_rating, + 'skill_change', 0.0, + 'uncertainty_change', uncertainty_change, + 'rating_value_change', rating_value_change + ) + FROM temp_table; + """ + + Ecto.Adapters.SQL.query(repo, query, []) + end) + |> Ecto.Multi.run(:update_ratings, fn repo, _ -> + query = """ + UPDATE teiserver_account_ratings tar + SET + uncertainty = t.new_uncertainty, + rating_value = t.new_rating, + leaderboard_rating = t.new_leaderboard_rating + FROM temp_table t + WHERE t.user_id = tar.user_id + and t.rating_type_id = tar.rating_type_id; + """ + + Ecto.Adapters.SQL.query(repo, query, []) + end) + |> Teiserver.Repo.transaction() + + with {:ok, result} <- sql_transaction_result do + time_taken = System.system_time(:millisecond) - start_time + + Logger.info( + "SeasonalUncertaintyResetTask complete, took #{time_taken}ms. Updated #{result.update_ratings.num_rows} ratings." + ) + else + _ -> + Logger.error("SeasonalUncertaintyResetTask failed.") + end + end +end diff --git a/priv/repo/migrations/20241209014803_season_uncertainty_function.exs b/priv/repo/migrations/20241209014803_season_uncertainty_function.exs new file mode 100644 index 000000000..433266d8c --- /dev/null +++ b/priv/repo/migrations/20241209014803_season_uncertainty_function.exs @@ -0,0 +1,58 @@ +defmodule Teiserver.Repo.Migrations.SeasonUncertaintyFunction do + use Ecto.Migration + + def up do + query = """ + create or replace function calculate_season_uncertainty(current_uncertainty float, last_updated timestamp, min_uncertainty float default 5) + returns float + language plpgsql + as + $$ + declare + -- variable declaration + default_uncertainty float; + days_not_played float; + one_month float; + one_year float; + interpolated_uncertainty float; + min_days float; + max_days float; + max_uncertainty float; + begin + -- Your new uncertainty will be: greatest(target_uncertainty, current_uncertainty) + -- Where target_uncertainty will be default if you have not played for over a year + -- 5 (min_uncertainty) if you have played within one month + -- And use linear interpolation for values in between + one_year = 365.0; + default_uncertainty = 25.0/3; + one_month = one_year / 12; + days_not_played = abs(DATE_PART('day', (now()- last_updated ))); + min_days = one_month; + max_days = one_year; + max_uncertainty = default_uncertainty; + + if(days_not_played >= max_days) then + return default_uncertainty; + elsif days_not_played <= min_days then + return GREATEST(current_uncertainty, min_uncertainty); + else + -- Use linear interpolation + interpolated_uncertainty = min_uncertainty +(days_not_played - min_days) * (max_uncertainty - min_uncertainty) /(max_days - min_days); + + return GREATEST(current_uncertainty, interpolated_uncertainty); + + end if; + end; + $$; + + + """ + + execute(query) + end + + def down do + query = "drop function calculate_season_uncertainty" + execute(query) + end +end diff --git a/test/teiserver/sql/season_uncertainty_reset_test.exs b/test/teiserver/sql/season_uncertainty_reset_test.exs new file mode 100644 index 000000000..cb257075c --- /dev/null +++ b/test/teiserver/sql/season_uncertainty_reset_test.exs @@ -0,0 +1,76 @@ +defmodule Teiserver.Sql.SeasonUncertaintyResetTest do + @moduledoc false + use Teiserver.DataCase + + test "it can calculate seasonal uncertainty reset target" do + # Start by removing all anon properties + {:ok, now} = DateTime.now("Etc/UTC") + + result = calculate_seasonal_uncertainty(now) + assert result == 5 + + # Now 30 days ago + month = 365 / 12 + last_updated = DateTime.add(now, -month |> trunc(), :day) + result = calculate_seasonal_uncertainty(last_updated) + + # assert_in_delta checks that the result is close to expected. Helps deal with rounding issues + assert_in_delta(result, 5, 0.1) + + # Now 2 months ago + last_updated = DateTime.add(now, (-2 * month) |> trunc(), :day) + result = calculate_seasonal_uncertainty(last_updated) + assert_in_delta(result, 5.294728102947281, 0.1) + + # Now 3 months ago + last_updated = DateTime.add(now, (-3 * month) |> trunc(), :day) + result = calculate_seasonal_uncertainty(last_updated) + assert_in_delta(result, 5.6035699460357, 0.1) + + # Now 6 months ago + last_updated = DateTime.add(now, (-6 * month) |> trunc(), :day) + result = calculate_seasonal_uncertainty(last_updated) + assert_in_delta(result, 6.510170195101702, 0.1) + + # Now 9 months ago + last_updated = DateTime.add(now, (-9 * month) |> trunc(), :day) + result = calculate_seasonal_uncertainty(last_updated) + assert_in_delta(result, 7.416770444167705, 0.1) + + # Now 11 months ago + last_updated = DateTime.add(now, (-11 * month) |> trunc(), :day) + result = calculate_seasonal_uncertainty(last_updated) + assert_in_delta(result, 8.024491490244916, 0.1) + + # Now 12 months ago + last_updated = DateTime.add(now, (-12 * month) |> trunc(), :day) + result = calculate_seasonal_uncertainty(last_updated) + assert_in_delta(result, 8.333333333333334, 0.1) + + # Now 13 months ago + last_updated = DateTime.add(now, (-13 * month) |> trunc(), :day) + + result = calculate_seasonal_uncertainty(last_updated) + assert_in_delta(result, 8.333333333333334, 0.1) + end + + # This will calculate the new uncertainty during a season reset + # The longer your last_updated is from today, the more your uncertainty will be reset + # The number should range from min_uncertainty and default_uncertainty (8.333) + # Your new_uncertainty can grow from current_uncertainty but never reduce + # Full details in comments of the sql function calculate_season_uncertainty + defp calculate_seasonal_uncertainty(last_updated) do + current_uncertainty = 1 + min_uncertainty = 5 + query = "SELECT calculate_season_uncertainty($1, $2, $3);" + + results = + Ecto.Adapters.SQL.query!(Repo, query, [current_uncertainty, last_updated, min_uncertainty]) + + [new_uncertainty] = + results.rows + |> Enum.at(0) + + new_uncertainty + end +end