diff --git a/lib/teiserver/account.ex b/lib/teiserver/account.ex index 8adda79a6..4cdcf4900 100644 --- a/lib/teiserver/account.ex +++ b/lib/teiserver/account.ex @@ -1570,9 +1570,6 @@ defmodule Teiserver.Account do @spec check_block_status(T.userid(), [T.userid()]) :: :ok | :blocking | :blocked defdelegate check_block_status(userid, userid_list), to: RelationshipLib - @spec check_avoid_status(T.userid(), [T.userid()]) :: :ok | :avoiding | :avoided - defdelegate check_avoid_status(userid, userid_list), to: RelationshipLib - @spec profile_view_permissions( T.user(), T.user(), diff --git a/lib/teiserver/account/libs/relationship_lib.ex b/lib/teiserver/account/libs/relationship_lib.ex index c5c72fa4b..95645b711 100644 --- a/lib/teiserver/account/libs/relationship_lib.ex +++ b/lib/teiserver/account/libs/relationship_lib.ex @@ -4,6 +4,7 @@ defmodule Teiserver.Account.RelationshipLib do alias Teiserver.Account.AuthLib alias Teiserver.Data.Types, as: T alias Phoenix.PubSub + alias Teiserver.Repo @spec colour :: atom def colour(), do: :success @@ -169,13 +170,14 @@ defmodule Teiserver.Account.RelationshipLib do end) end + # Blocks will be treated as avoids, but avoids will not be treated as blocks @spec list_userids_avoiding_this_userid(T.userid()) :: [T.userid()] def list_userids_avoiding_this_userid(userid) do Teiserver.cache_get_or_store(:account_avoiding_this_cache, userid, fn -> Account.list_relationships( where: [ to_user_id: userid, - state: "avoid" + state_in: ["block", "avoid"] ], select: [:from_user_id] ) @@ -185,13 +187,14 @@ defmodule Teiserver.Account.RelationshipLib do end) end + # Blocks will be treated as avoids, but avoids will not be treated as blocks @spec list_userids_avoided_by_userid(T.userid()) :: [T.userid()] def list_userids_avoided_by_userid(userid) do Teiserver.cache_get_or_store(:account_avoid_cache, userid, fn -> Account.list_relationships( where: [ from_user_id: userid, - state: "avoid" + state_in: ["block", "avoid"] ], select: [:to_user_id] ) @@ -201,13 +204,15 @@ defmodule Teiserver.Account.RelationshipLib do end) end + # Get userids blocked by a userid + # Avoids do not count as blocks @spec list_userids_blocked_by_userid(T.userid()) :: [T.userid()] def list_userids_blocked_by_userid(userid) do Teiserver.cache_get_or_store(:account_block_cache, userid, fn -> Account.list_relationships( where: [ from_user_id: userid, - state_in: ["avoid", "block"] + state_in: ["block"] ], select: [:to_user_id] ) @@ -217,13 +222,15 @@ defmodule Teiserver.Account.RelationshipLib do end) end + # Get userids blocking a userid + # Avoids do not count as blocks @spec list_userids_blocking_this_userid(T.userid()) :: [T.userid()] def list_userids_blocking_this_userid(userid) do Teiserver.cache_get_or_store(:account_blocking_this_cache, userid, fn -> Account.list_relationships( where: [ to_user_id: userid, - state_in: ["avoid", "block"] + state_in: ["block"] ], select: [:from_user_id] ) @@ -298,7 +305,6 @@ defmodule Teiserver.Account.RelationshipLib do @spec check_block_status(T.userid(), [T.userid()]) :: :ok | :blocking | :blocked def check_block_status(userid, userid_list) do - user = Account.get_user_by_id(userid) userid_count = Enum.count(userid_list) |> max(1) block_count_needed = Config.get_site_config_cache("lobby.Block count to prevent join") @@ -330,37 +336,57 @@ defmodule Teiserver.Account.RelationshipLib do end end - @spec check_avoid_status(T.userid(), [T.userid()]) :: :ok | :avoiding | :avoided - def check_avoid_status(userid, userid_list) do - user = Account.get_user_by_id(userid) - userid_count = Enum.count(userid_list) |> max(1) - - avoid_count_needed = Config.get_site_config_cache("lobby.Avoid count to prevent playing") - - avoid_percentage_needed = - Config.get_site_config_cache("lobby.Avoid percentage to prevent playing") - - being_avoided_count = - userid - |> list_userids_avoiding_this_userid() - |> Enum.count(fn uid -> Enum.member?(userid_list, uid) end) - - avoiding_count = - userid - |> list_userids_avoided_by_userid() - |> Enum.count(fn uid -> Enum.member?(userid_list, uid) end) - - being_avoided_percentage = being_avoided_count / userid_count * 100 - avoiding_percentage = avoiding_count / userid_count * 100 + # Given a list of players in lobby, gets all relevant avoids + # Use limit to only pull a maximum number from the database (prefer oldest) + # Use player_limit to only pull a maximum number per player (prefer oldest) + def get_lobby_avoids(player_ids, limit, player_limit) do + query = """ + select from_user_id, to_user_id from ( + select from_user_id, to_user_id, state, inserted_at, row_number() over (partition by arel.from_user_id order by arel.inserted_at asc) as rn + from account_relationships arel + where state in('avoid','block') + and from_user_id =ANY($1) + and to_user_id =ANY($2) + and state in('avoid','block') + ) as ar + where + rn <= $4 + order by inserted_at asc + limit $3 + """ + + results = Ecto.Adapters.SQL.query!(Repo, query, [player_ids, player_ids, limit, player_limit]) + + results.rows + end - cond do - # You are being avoided - being_avoided_percentage >= avoid_percentage_needed -> :avoided - being_avoided_count >= avoid_count_needed -> :avoided - # You are avoiding - avoiding_percentage >= avoid_percentage_needed -> :avoiding - avoiding_count >= avoid_count_needed -> :avoiding - true -> :ok - end + def get_lobby_avoids(player_ids, limit, player_limit, minimum_time_hours) do + query = """ + select from_user_id, to_user_id from ( + select from_user_id, to_user_id, state, inserted_at, row_number() over (partition by arel.from_user_id order by arel.inserted_at asc) as rn + from account_relationships arel + where state in('avoid','block') + and from_user_id =ANY($1) + and to_user_id =ANY($2) + and state in('avoid','block') + and inserted_at <= (now() - interval '#{minimum_time_hours} hours') + ) as ar + where + rn <= $4 + order by inserted_at asc + limit $3 + """ + + # Not able to use mimimum_time_hours as parameter so have to add into the sql string + + results = + Ecto.Adapters.SQL.query!(Repo, query, [ + player_ids, + player_ids, + limit, + player_limit + ]) + + results.rows end end diff --git a/lib/teiserver/battle/balance/brute_force_avoid.ex b/lib/teiserver/battle/balance/brute_force_avoid.ex new file mode 100644 index 000000000..8aa94d5ca --- /dev/null +++ b/lib/teiserver/battle/balance/brute_force_avoid.ex @@ -0,0 +1,172 @@ +defmodule Teiserver.Battle.Balance.BruteForceAvoid do + @moduledoc """ + Overview: + Go through every possible combination and pick the best one. Each combination is given a score + Score = team rating penalty + broken avoid penalty + broken party penalty + broken max team rating diff penalty + team rating penalty = difference in team ratings + broken avoid penalty = broken avoid count * avoid importance + broken party penalty = broken party count * party importance + broken max team rating diff penalty = max team diff importance (if the team rating diff > @max_team_diff) + + Only use for games with two teams. + + This is not a balance algorithm that is callable players. It's a lib that can be used by another balance algorithm. + """ + alias Teiserver.Config + alias Teiserver.Battle.Balance.BruteForceAvoidTypes, as: BF + require Integer + + # Parties will be split if team diff is too large. It either uses absolute value or percentage + # See get_max_team_diff function below for full details + @max_team_diff_abs 10 + @max_team_diff_importance 10000 + @party_importance 1000 + @avoid_importance 10 + + def get_best_combo(players, avoids, parties) do + potential_teams = potential_teams(length(players)) + get_best_combo(potential_teams, players, avoids, parties) + end + + @spec get_best_combo([integer()], [BF.player()], [[number()]], [[number()]]) :: + BF.combo_result() + def get_best_combo(combos, players, avoids, parties) do + players_with_index = Enum.with_index(players) + + # Go through every possibility and get the combination with the lowest score + result = + Enum.map(combos, fn x -> + team = get_players_from_indexes(x, players_with_index) + result = score_combo(team, players, avoids, parties) + Map.put(result, :first_team, team) + end) + |> Enum.min_by(fn z -> + z.score + end) + + first_team = result.first_team + + second_team = + players + |> Enum.filter(fn x -> + !Enum.any?(first_team, fn y -> + y.id == x.id + end) + end) + + Map.put(result, :second_team, second_team) + end + + @spec potential_teams(integer()) :: any() + def potential_teams(num_players) do + Teiserver.Helper.CombinationsHelper.get_combinations(num_players) + end + + # Parties/avoids will be ignored if the team rating diff is too large + # This function returns the allowed team difference + # By default, it is either 10 rating points or 5% of a single team rating - whichever is larger + defp get_max_team_diff(total_lobby_rating, num_teams) do + # This value is 10% in dev but 5% in production. Can be adjusted by Admin + percentage_of_team = Config.get_site_config_cache("teiserver.Max deviation") / 100 + max(total_lobby_rating / num_teams * percentage_of_team, @max_team_diff_abs) + end + + @spec score_combo([BF.player()], [BF.player()], [[number()]], [[number()]]) :: any() + def score_combo(first_team, all_players, avoids, parties) do + first_team_rating = get_team_rating(first_team) + both_team_rating = get_team_rating(all_players) + rating_diff_penalty = abs(both_team_rating - first_team_rating * 2) + num_teams = 2 + + max_team_diff_penalty = + cond do + rating_diff_penalty > get_max_team_diff(both_team_rating, num_teams) -> + @max_team_diff_importance + + true -> + 0 + end + + # If max_team_diff_penalty is non zero don't even bother calculating avoid and party penalty + # since it's likely we'll discard this combo + {broken_avoid_penalty, broken_party_penalty} = + case max_team_diff_penalty do + 0 -> + {count_broken_avoids(first_team, avoids) * @avoid_importance, + count_broken_parties(first_team, parties) * @party_importance} + + _ -> + {0, 0} + end + + score = + rating_diff_penalty + broken_avoid_penalty + broken_party_penalty + max_team_diff_penalty + + %{ + score: score, + rating_diff_penalty: rating_diff_penalty, + broken_avoid_penalty: broken_avoid_penalty, + broken_party_penalty: broken_party_penalty + } + end + + def get_players_from_indexes(player_indexes, players_with_index) do + players_with_index + |> Enum.filter(fn {_player, index} -> index in player_indexes end) + |> Enum.map(fn {player, _index} -> player end) + end + + def count_broken_parties(first_team, parties) do + Enum.count(parties, fn party -> + is_party_broken?(first_team, party) + end) + end + + @spec is_party_broken?([BF.player()], [String.t()]) :: any() + def is_party_broken?(team, party) do + count = + Enum.count(party, fn x -> + Enum.any?(team, fn y -> + y.id == x + end) + end) + + cond do + # Nobody from this party is on this team. Therefore unbroken. + count == 0 -> false + # Everyone from this party is on this team. Therefore unbroken. + count == length(party) -> false + # Otherwise, this party is broken. + true -> true + end + end + + def count_broken_avoids(first_team, avoids) do + Enum.count(avoids, fn avoid -> + is_avoid_broken?(first_team, avoid) + end) + end + + @spec is_avoid_broken?([BF.player()], [[any()]]) :: any() + def is_avoid_broken?(team, avoids) do + count = + Enum.count(avoids, fn x -> + Enum.any?(team, fn y -> + y.id == x + end) + end) + + cond do + # One person from avoid on this team. The other must be on other team. Avoid is respected. + count == 1 -> false + # Otherwise avoid is broken + true -> true + end + end + + defp get_team_rating(players) do + Enum.reduce(players, 0, fn x, acc -> + acc + x.rating + end) + end +end diff --git a/lib/teiserver/battle/balance/brute_force_avoid_types.ex b/lib/teiserver/battle/balance/brute_force_avoid_types.ex new file mode 100644 index 000000000..cd11c1bee --- /dev/null +++ b/lib/teiserver/battle/balance/brute_force_avoid_types.ex @@ -0,0 +1,26 @@ +defmodule Teiserver.Battle.Balance.BruteForceAvoidTypes do + @moduledoc false + + @type player :: %{ + rating: float(), + id: any(), + name: String.t() + } + @type team :: %{ + players: [player], + id: integer() + } + @type input_data :: %{ + players: [player], + parties: [String.t()] + } + + @type combo_result :: %{ + broken_avoid_penalty: number(), + broken_party_penalty: number(), + rating_diff_penalty: number(), + score: number(), + first_team: [player()], + second_team: [player()] + } +end diff --git a/lib/teiserver/battle/balance/respect_avoids.ex b/lib/teiserver/battle/balance/respect_avoids.ex new file mode 100644 index 000000000..73371ddcd --- /dev/null +++ b/lib/teiserver/battle/balance/respect_avoids.ex @@ -0,0 +1,530 @@ +defmodule Teiserver.Battle.Balance.RespectAvoids do + @moduledoc """ + A balance algorithm that tries to keep avoided players on seperate teams. + + High uncertainty players are avoid immune because we classify those as noobs and want to spread + them evenly across teams (like split_noobs). + + Parties will have signficantly higher importance than avoids. To limit the amount of computation, the amount of + avoids is limited for higher player counts. + """ + alias Teiserver.Battle.Balance.BalanceTypes, as: BT + alias Teiserver.Battle.Balance.RespectAvoidsTypes, as: RA + alias Teiserver.Battle.Balance.BruteForceAvoid + import Teiserver.Helper.NumberHelper, only: [format: 1] + alias Teiserver.Account.RelationshipLib + alias Teiserver.Config + # If player uncertainty is greater than equal to this, that player is considered a noob + # The lowest uncertainty rank 0 player at the time of writing this is 6.65 + @high_uncertainty 6.65 + @splitter "------------------------------------------------------" + @per_player_avoid_limit 2 + + @doc """ + Main entry point used by balance_lib + """ + @spec perform([BT.expanded_group()], non_neg_integer(), list()) :: any() + def perform(expanded_group, team_count, opts \\ []) do + debug_mode? = Keyword.get(opts, :debug_mode?, false) + initial_state = get_initial_state(expanded_group, debug_mode?) + + case should_use_algo(team_count) do + :ok -> + result = get_result(initial_state) + standardise_result(result, initial_state) + + {:error, message, alternate_balancer} -> + # Call another balancer + result = alternate_balancer.perform(expanded_group, team_count, opts) + + new_logs = + [ + "#{message}", + result.logs + ] + |> List.flatten() + + Map.put(result, :logs, new_logs) + end + end + + @spec get_initial_state([BT.expanded_group()], boolean()) :: RA.state() + def get_initial_state(expanded_group, debug_mode?) do + players = flatten_members(expanded_group) + parties = get_parties(expanded_group) + + noobs = get_noobs(players) |> sort_noobs() + experienced_players = get_experienced_players(players, noobs) + experienced_player_ids = experienced_players |> Enum.map(fn x -> x.id end) + players_in_parties_count = parties |> List.flatten() |> Enum.count() + lobby_max_avoids = get_max_avoids(Enum.count(players), players_in_parties_count) + avoids = get_avoids(experienced_player_ids, lobby_max_avoids, debug_mode?) + + experienced_players = + experienced_players + |> sort_experienced_players(avoids) + |> Enum.with_index(fn element, index -> + element |> Map.put(:index, index + 1) + end) + + # top_experienced are the players we will feed into brute force algo + # We limit to 14 players or it will take too long + index_cut_off = 14 + + top_experienced = + experienced_players + |> Enum.filter(fn player -> + player.index <= index_cut_off + end) + + bottom_experienced = + experienced_players + |> Enum.filter(fn player -> + player.index > index_cut_off + end) + + %{ + parties: parties, + players: players, + avoids: avoids, + noobs: noobs, + top_experienced: top_experienced, + bottom_experienced: bottom_experienced, + debug_mode?: debug_mode?, + lobby_max_avoids: lobby_max_avoids + } + end + + @spec should_use_algo(integer()) :: + :ok | {:error, String.t(), any()} + def should_use_algo(team_count) do + # If team count not two, then call loser_picks + # Otherwise return :ok + cond do + team_count != 2 -> + {:error, "Team count not equal to 2. Will use loser_picks algorithm instead.", + Teiserver.Battle.Balance.LoserPicks} + + true -> + :ok + end + end + + @spec standardise_result(RA.result() | RA.simple_result(), RA.state()) :: any() + def standardise_result(result, state) do + first_team = result.first_team + second_team = result.second_team + + team_groups = %{ + 1 => standardise_team_groups(first_team), + 2 => standardise_team_groups(second_team) + } + + team_players = %{ + 1 => standardise_team_players(first_team), + 2 => standardise_team_players(second_team) + } + + noob_log = + cond do + length(state.noobs) > 0 -> + noobs_string = + Enum.map(state.noobs, fn x -> + chev = Map.get(x, :rank, 0) + 1 + "#{x.name} (chev: #{chev}, σ: #{format(x.uncertainty)})" + end) + + [ + "High uncertainty players (avoid immune):", + noobs_string + ] + + true -> + "New players: None" + end + + logs = + (get_initial_logs(state) ++ + [ + noob_log, + @splitter, + result.logs, + @splitter, + "Final result:", + "Team 1: #{log_team(first_team)}", + "Team 2: #{log_team(second_team)}" + ]) + |> List.flatten() + + %{ + team_groups: team_groups, + team_players: team_players, + logs: logs + } + end + + @spec get_initial_logs(RA.state()) :: [String.t()] + defp get_initial_logs(state) do + max_avoids = state.lobby_max_avoids + + max_avoid_text = + cond do + max_avoids != nil && max_avoids <= 20 -> " (Max: #{max_avoids})" + true -> "" + end + + avoid_text = "Avoids considered: #{Enum.count(state.avoids)}" <> max_avoid_text + avoid_min_hours = get_avoid_delay() + avoid_delay_text = "Avoid min time required: #{avoid_min_hours} h" + + [ + @splitter, + "Algorithm: respect_avoids", + @splitter, + "This algorithm will try and respect parties and avoids of players so long as it can keep team rating difference within certain bounds. Parties have higher importance than avoids.", + "Recent avoids will be ignored. New players will be spread evenly across teams and cannot be avoided.", + @splitter, + "Lobby details:", + "Parties: #{get_party_logs(state)}", + avoid_delay_text, + avoid_text, + @splitter + ] + end + + defp get_party_logs(state) do + if(Enum.count(state.parties) > 0) do + state.parties + |> Enum.map(fn party -> + player_names = + Enum.map(party, fn x -> + get_player_name(x, state.players) + end) + + "(#{Enum.join(player_names, ", ")})" + end) + |> Enum.join(", ") + else + "None" + end + end + + defp get_player_name(id, players) do + Enum.find(players, %{name: "error"}, fn x -> x.id == id end).name + end + + @spec log_team([RA.player()]) :: String.t() + defp log_team(team) do + Enum.map(team, fn x -> + x.name + end) + |> Enum.reverse() + |> Enum.join(", ") + end + + @spec standardise_team_groups([RA.player()]) :: any() + defp standardise_team_groups(team) do + team + |> Enum.map(fn x -> + %{ + members: [x.id], + count: 1, + group_rating: x.rating, + ratings: [x.rating] + } + end) + end + + defp standardise_team_players(team) do + team + |> Enum.map(fn x -> + x.id + end) + end + + # Get the most amount of avoids to be pulled from the database (per lobby) + # For high player counts, we need a limit for performance reasons + # For lower player counts, we have no limit + def get_max_avoids(player_count, players_in_parties_count) do + cond do + # 7v7 and above + player_count >= 14 -> + # For 7v7 and above, if there are no parties, we pull at most 7 avoids from the database + # For every two players in parties, we reduce the number of avoids by 1 to a minimum of 1 + # This is for performance reasons as processing parties and avoids takes time + max(1, ((14 - players_in_parties_count) / 2) |> trunc()) + + # Anything else + true -> + nil + end + end + + @spec get_parties([BT.expanded_group()]) :: [String.t()] + def get_parties(expanded_group) do + Enum.filter(expanded_group, fn x -> + x[:count] >= 2 + end) + |> Enum.map(fn y -> + # These are ids not names + y[:members] + end) + end + + @spec get_result(RA.state()) :: RA.result() | RA.simple_result() + def get_result(state) do + if(Enum.count(state.parties) == 0 && Enum.count(state.avoids) == 0) do + do_simple_draft(state) + else + do_brute_force_and_draft(state) + end + end + + defp do_brute_force_and_draft(state) do + # This is the best combo with only the top 14 experienced players + # This means we brute force at most 14 playesr + combo_result = + BruteForceAvoid.get_best_combo(state.top_experienced, state.avoids, state.parties) + + # These are the remaining players who were not involved in the brute force algorithm + remaining = state.bottom_experienced ++ state.noobs + + logs = [ + "Perform brute force with the following players to get the best score.", + "Players: #{Enum.join(Enum.map(state.top_experienced, fn x -> x.name end), ", ")}", + @splitter, + "Brute force result:", + "Team rating diff penalty: #{format(combo_result.rating_diff_penalty)}", + "Broken party penalty: #{combo_result.broken_party_penalty}", + "Broken avoid penalty: #{combo_result.broken_avoid_penalty}", + "Score: #{format(combo_result.score)} (lower is better)", + @splitter, + "Draft remaining players (ordered from best to worst).", + "Remaining: #{Enum.join(Enum.map(remaining, fn x -> x.name end), ", ")}" + ] + + default_acc = combo_result + + # Draft the remaining players + Enum.reduce(remaining, default_acc, fn noob, acc -> + picking_team = get_picking_team(acc.first_team, acc.second_team) + + Map.put(acc, picking_team, [noob | acc[picking_team]]) + end) + |> Map.put(:logs, logs) + end + + @spec do_simple_draft(RA.state()) :: RA.simple_result() + defp do_simple_draft(state) do + default_acc = %{ + first_team: [], + second_team: [] + } + + experienced_players = state.top_experienced ++ state.bottom_experienced + + noobs = state.noobs + sorted_players = experienced_players ++ noobs + + Enum.reduce(sorted_players, default_acc, fn x, acc -> + picking_team = get_picking_team(acc.first_team, acc.second_team) + + Map.put(acc, picking_team, [x | acc[picking_team]]) + end) + |> Map.put(:logs, ["Teams constructed by simple draft"]) + end + + def get_picking_team(first_team, second_team) do + first_team_pick_priority = get_pick_priority(first_team) + second_team_pick_priority = get_pick_priority(second_team) + + if(first_team_pick_priority > second_team_pick_priority) do + :first_team + else + :second_team + end + end + + # Higher pick priority means that team should pick + defp get_pick_priority(team) do + team_rating = get_team_rating(team) + captain_rating = get_captain_rating(team) + # Prefer smaller rating + rating_importance = -1 + # Prefer team with less players + size_importance = -100 + # Prefer weaker captain + captain_importance = -1 + + # Score + team_rating * rating_importance + length(team) * size_importance + + captain_rating * captain_importance + end + + defp get_captain_rating(team) do + if Enum.count(team) > 0 do + captain = + Enum.max_by(team, fn x -> + x.rating + end) + + captain[:rating] + else + 0 + end + end + + defp get_team_rating(team) do + Enum.reduce(team, 0, fn x, acc -> + acc + x.rating + end) + end + + def sort_noobs(noobs) do + # Prefer higher rank + rank_importance = 100 + # Prefer lower uncertainty + uncertainty_importance = -1 + + Enum.sort_by( + noobs, + fn noob -> + rank = Map.get(noob, :rank, 0) + uncertainty = Map.get(noob, :uncertainty, 8.33) + rank_importance * rank + uncertainty_importance * uncertainty + end, + :desc + ) + end + + @doc """ + Converts the input to a simple list of players + """ + @spec flatten_members([BT.expanded_group()]) :: any() + def flatten_members(expanded_group) do + for %{ + members: members, + ratings: ratings, + ranks: ranks, + names: names, + uncertainties: uncertainties, + count: count + } <- expanded_group, + # Zipping will create binary tuples from 2 lists + {id, rating, rank, name, uncertainty} <- + Enum.zip([members, ratings, ranks, names, uncertainties]), + # Create result value + do: %{ + rating: adjusted_rating(rating, uncertainty, rank), + name: name, + id: id, + uncertainty: uncertainty, + rank: rank, + in_party?: + cond do + count <= 1 -> false + true -> true + end + } + end + + # This balance algorithm will use an adjusted rating for newish players + # This will not be displayed in chobby ui or player list; it's only used for balance + # It will be used when calculating team deviation + defp adjusted_rating(rating, uncertainty, rank) do + if(is_newish_player?(rank, uncertainty)) do + # For newish players we assume they are the worst in the lobby e.g. 0 match rating and + # then they converge to their true rating over time + # Once their uncertainty is low enough, we fully trust their rating + {_skill, starting_uncertainty} = Openskill.rating() + + uncertainty_cutoff = @high_uncertainty + + min( + 1, + (starting_uncertainty - uncertainty) / + (starting_uncertainty - uncertainty_cutoff) + ) * rating + else + rating + end + end + + @spec get_avoids([any()], number(), boolean()) :: [String.t()] + def get_avoids(player_ids, lobby_max_avoids, debug_mode? \\ false) do + cond do + debug_mode? -> + RelationshipLib.get_lobby_avoids(player_ids, lobby_max_avoids, @per_player_avoid_limit) + + true -> + avoid_min_hours = get_avoid_delay() + + RelationshipLib.get_lobby_avoids( + player_ids, + lobby_max_avoids, + @per_player_avoid_limit, + avoid_min_hours + ) + end + end + + @spec get_avoid_delay() :: number() + defp get_avoid_delay() do + Config.get_site_config_cache("lobby.Avoid min hours required") + end + + ## Gets experienced players + def get_experienced_players(players, noobs) do + Enum.filter(players, fn player -> + !Enum.any?(noobs, fn noob -> + noob.name == player.name + end) + end) + end + + ## Players that are in parties or avoids (in either direction) are at the front, then sort by rating + @spec sort_experienced_players([RA.player()], [[number()]]) :: [RA.player()] + def sort_experienced_players(experienced_players, avoids) do + flat_avoids = avoids |> List.flatten() + + experienced_players + |> Enum.sort_by( + fn player -> + is_in_avoid_list? = + Enum.any?(flat_avoids, fn id -> + id == player.id + end) + + avoid_relevance = + case is_in_avoid_list? do + true -> 100 + false -> 0 + end + + party_relevance = + case player.in_party? do + true -> 1000 + false -> 0 + end + + player.rating + avoid_relevance + party_relevance + end, + :desc + ) + end + + # Noobs have high uncertainty and chev 1,2,3 + @spec get_noobs([RA.player()]) :: any() + def get_noobs(players) do + Enum.filter(players, fn player -> + is_newish_player?(player.rank, player.uncertainty) + end) + end + + def is_newish_player?(rank, uncertainty) do + # It is possible that someone has high uncertainty due to + # playing unranked, playing PvE, or playing a different game mode e.g. 1v1 + # If they have many hours i.e. chev 4 = 100 hours, we will not consider them newish + uncertainty >= @high_uncertainty && rank <= 2 + end +end diff --git a/lib/teiserver/battle/balance/respect_avoids_types.ex b/lib/teiserver/battle/balance/respect_avoids_types.ex new file mode 100644 index 000000000..a63272eaf --- /dev/null +++ b/lib/teiserver/battle/balance/respect_avoids_types.ex @@ -0,0 +1,41 @@ +defmodule Teiserver.Battle.Balance.RespectAvoidsTypes do + @moduledoc false + + @type player :: %{ + rating: float(), + id: any(), + name: String.t(), + uncertainty: float(), + in_party?: boolean() + } + @type team :: %{ + players: [player], + id: integer() + } + + @type state :: %{ + players: [player], + avoids: [[number()]], + parties: [[number()]], + noobs: [player], + top_experienced: [player], + bottom_experienced: [player], + debug_mode?: boolean(), + lobby_max_avoids: number() + } + + @type simple_result :: %{ + first_team: [player()], + second_team: [player()], + logs: [String.t()] + } + + @type result :: %{ + broken_avoid_penalty: number(), + rating_diff_penalty: number(), + score: number(), + first_team: [player()], + second_team: [player()], + logs: [String.t()] + } +end diff --git a/lib/teiserver/battle/libs/balance_lib.ex b/lib/teiserver/battle/libs/balance_lib.ex index 9a2f5cf94..7d8444f2a 100644 --- a/lib/teiserver/battle/libs/balance_lib.ex +++ b/lib/teiserver/battle/libs/balance_lib.ex @@ -51,7 +51,8 @@ defmodule Teiserver.Battle.BalanceLib do "force_party" => Teiserver.Battle.Balance.ForceParty, "brute_force" => Teiserver.Battle.Balance.BruteForce, "split_noobs" => Teiserver.Battle.Balance.SplitNoobs, - "auto" => Teiserver.Battle.Balance.AutoBalance + "auto" => Teiserver.Battle.Balance.AutoBalance, + "respect_avoids" => Teiserver.Battle.Balance.RespectAvoids } end diff --git a/lib/teiserver/coordinator/consul_server.ex b/lib/teiserver/coordinator/consul_server.ex index 6c174d592..7602349fc 100644 --- a/lib/teiserver/coordinator/consul_server.ex +++ b/lib/teiserver/coordinator/consul_server.ex @@ -803,11 +803,8 @@ defmodule Teiserver.Coordinator.ConsulServer do @spec user_allowed_to_play?(T.user(), T.client(), map()) :: boolean() defp user_allowed_to_play?(user, client, state) do - player_ids = list_player_ids(state) userid = user.id - avoid_status = Account.check_avoid_status(user.id, player_ids) - rating_check_result = LobbyRestrictions.check_rating_to_play(userid, state) rank_check_result = LobbyRestrictions.check_rank_to_play(user, state) @@ -829,20 +826,6 @@ defmodule Teiserver.Coordinator.ConsulServer do Account.is_moderator?(user) -> true - avoid_status == :avoiding -> - match_id = Battle.get_lobby_match_id(state.lobby_id) - Telemetry.log_simple_lobby_event(user.id, match_id, "play_refused.avoiding") - msg = "You are avoiding too many players in this lobby" - CacheUser.send_direct_message(get_coordinator_userid(), userid, msg) - false - - avoid_status == :avoided -> - match_id = Battle.get_lobby_match_id(state.lobby_id) - Telemetry.log_simple_lobby_event(user.id, match_id, "play_refused.avoided") - msg = "You are avoided by too many players in this lobby" - CacheUser.send_direct_message(get_coordinator_userid(), userid, msg) - false - true -> true end diff --git a/lib/teiserver/libs/teiserver_configs.ex b/lib/teiserver/libs/teiserver_configs.ex index c132f7e66..a443be199 100644 --- a/lib/teiserver/libs/teiserver_configs.ex +++ b/lib/teiserver/libs/teiserver_configs.ex @@ -385,23 +385,12 @@ defmodule Teiserver.TeiserverConfigs do }) add_site_config_type(%{ - key: "lobby.Avoid count to prevent playing", + key: "lobby.Avoid min hours required", section: "Lobbies", type: "integer", permissions: ["Admin"], - description: - "The raw number of players who would need to avoid someone to prevent them becoming a player", - default: 6 - }) - - add_site_config_type(%{ - key: "lobby.Avoid percentage to prevent playing", - section: "Lobbies", - type: "integer", - permissions: ["Admin"], - description: - "The percentage of players who would need to avoid someone to prevent them becoming a player", - default: 50 + description: "Avoids must be at least this old to be considered", + default: 2 }) add_site_config_type(%{ diff --git a/lib/teiserver/mix_tasks/party_balance_stats.ex b/lib/teiserver/mix_tasks/party_balance_stats.ex new file mode 100644 index 000000000..7e3f7b25f --- /dev/null +++ b/lib/teiserver/mix_tasks/party_balance_stats.ex @@ -0,0 +1,299 @@ +defmodule Mix.Tasks.Teiserver.PartyBalanceStats do + @moduledoc """ + Try and get stats on how well balancer keeps parties + If you want to run this task invidually, use: + mix teiserver.party_balance_stats + On integration server it is recommended you output to a specific path as follows: + mix teiserver.party_balance_stats /var/log/teiserver/results.txt + """ + + use Mix.Task + require Logger + alias Teiserver.Repo + alias Teiserver.{Battle, Game} + alias Teiserver.Battle.{BalanceLib} + alias Mix.Tasks.Teiserver.PartyBalanceStatsTypes, as: PB + alias Teiserver.Config + + def run(args) do + Logger.info("Args: #{args}") + write_log_filepath = Enum.at(args, 0, nil) + + Application.ensure_all_started(:teiserver) + game_types = ["Large Team", "Small Team"] + opts = [] + + result = + Enum.map(game_types, fn game_type -> + get_balance_test_results(game_type, opts) + end) + + # For each match id + + json_result = Jason.encode(result) + + case json_result do + {:ok, json_string} -> write_to_file(json_string, write_log_filepath) + end + + Logger.info("Finished processing matches") + end + + defp get_balance_test_results(game_type, opts) do + match_ids = get_match_ids(game_type) + max_deviation = Config.get_site_config_cache("teiserver.Max deviation") + balance_algos = ["loser_picks", "split_noobs", "respect_avoids"] + + balance_result = + Enum.map(balance_algos, fn algo -> + test_balancer(algo, match_ids, max_deviation, opts) + end) + + %{ + game_type: game_type, + balance_result: balance_result, + config_max_deviation: max_deviation + } + end + + defp test_balancer(algo, match_ids, max_deviation, opts) do + start_time = System.system_time(:microsecond) + # For each match id + result = + Enum.map(match_ids, fn match_id -> + process_match(match_id, algo, max_deviation, opts) + end) + + total_broken_parties = + Enum.map(result, fn x -> + x[:broken_party_count] + end) + |> Enum.sum() + + broken_party_match_ids = + result + |> Enum.filter(fn x -> + x[:broken_party_count] > 0 + end) + |> Enum.map(fn x -> + x[:match_id] + end) + + num_matches = length(match_ids) + time_taken = System.system_time(:microsecond) - start_time + + avg_time_taken = + case num_matches do + 0 -> 0 + _ -> time_taken / num_matches / 1000 + end + + %{ + algo: algo, + matches_processed: num_matches, + total_broken_parties: total_broken_parties, + broken_party_match_ids: broken_party_match_ids, + avg_time_taken: avg_time_taken + } + end + + @spec count_broken_parties(PB.balance_result()) :: any() + def count_broken_parties(balance_result) do + first_team = balance_result.team_players[1] + parties = balance_result.parties + count_broken_parties(first_team, parties) + end + + defp count_broken_parties(first_team, parties) do + Enum.count(parties, fn party -> + is_party_broken?(first_team, party) + end) + end + + @spec is_party_broken?([number()], [number()]) :: any() + defp is_party_broken?(team, party) do + count = + Enum.count(party, fn x -> + Enum.any?(team, fn y -> + y == x + end) + end) + + cond do + # Nobody from this party is on this team. Therefore unbroken. + count == 0 -> false + # Everyone from this party is on this team. Therefore unbroken. + count == length(party) -> false + # Otherwise, this party is broken. + true -> true + end + end + + defp process_match(id, algorithm, max_deviation, _opts) do + match = + Battle.get_match!(id, + preload: [:members_and_users] + ) + + members = match.members + + rating_logs = + Game.list_rating_logs( + search: [ + match_id: match.id + ] + ) + |> Map.new(fn log -> {log.user_id, log} end) + + past_balance = + make_balance(2, members, rating_logs, + algorithm: algorithm, + max_deviation: max_deviation + ) + + result = %{ + match_id: id, + team_players: past_balance[:team_players], + parties: past_balance[:parties] + } + + broken_party_count = count_broken_parties(result) + + Map.put(result, :broken_party_count, broken_party_count) + end + + @spec make_balance(non_neg_integer(), [any()], any(), list()) :: map() + defp make_balance(team_count, players, rating_logs, opts) do + party_result = make_grouped_balance(team_count, players, rating_logs, opts) + has_parties? = Map.get(party_result, :has_parties?, true) + + if has_parties? && party_result.deviation > opts[:max_deviation] do + solo_result = + make_solo_balance( + team_count, + players, + rating_logs, + opts + ) + + Map.put(solo_result, :parties, party_result.parties) + else + party_result + end + end + + @spec make_grouped_balance(non_neg_integer(), [any()], any(), list()) :: map() + defp make_grouped_balance(team_count, players, rating_logs, opts) do + # Group players into parties + partied_players = + players + |> Enum.group_by(fn p -> p.party_id end, fn p -> p.user_id end) + + groups = + partied_players + |> Enum.map(fn + # The nil group is players without a party, they need to + # be broken out of the party + {nil, player_id_list} -> + player_id_list + |> Enum.map(fn userid -> + %{userid => rating_logs[userid].value} + end) + + {_party_id, player_id_list} -> + player_id_list + |> Map.new(fn userid -> + {userid, rating_logs[userid].value} + end) + end) + |> List.flatten() + + BalanceLib.create_balance(groups, team_count, opts) + |> Map.put(:balance_mode, :grouped) + |> Map.put(:parties, get_parties(partied_players)) + end + + @spec make_solo_balance(non_neg_integer(), [any()], any(), list()) :: + map() + defp make_solo_balance(team_count, players, rating_logs, opts) do + groups = + players + |> Enum.map(fn %{user_id: userid} -> + %{userid => rating_logs[userid].value} + end) + + result = BalanceLib.create_balance(groups, team_count, opts) + + Map.merge(result, %{ + balance_mode: :solo + }) + end + + defp get_match_ids("Large Team") do + get_match_ids(8, 2) + end + + defp get_match_ids("Small Team") do + get_match_ids(5, 2) + end + + defp get_match_ids(team_size, team_count) do + query = """ + select distinct tbm.id, tbm.inserted_at from teiserver_battle_match_memberships tbmm + inner join teiserver_battle_matches tbm + on tbm.id = tbmm.match_id + and tbm.team_size = $1 + and tbm.team_count = $2 + inner join teiserver_game_rating_logs tgrl + on tgrl.match_id = tbm.id + and tgrl.value is not null + where tbmm.party_id is not null + order by tbm.inserted_at DESC + limit 100; + """ + + results = Ecto.Adapters.SQL.query!(Repo, query, [team_size, team_count]) + + results.rows + |> Enum.map(fn [id, _insert_date] -> + id + end) + end + + defp get_parties(partied_players) do + partied_players + |> Enum.map(fn + # The nil group is players without a party, they need to + # be broken out of the party + {nil, _player_id_list} -> + nil + + {_party_id, player_id_list} -> + player_id_list + end) + |> Enum.filter(fn x -> + x != nil + end) + end + + defp write_to_file(contents, nil) do + app_dir = File.cwd!() + new_file_path = Path.join([app_dir, "results.txt"]) + + write_to_file(contents, new_file_path) + end + + defp write_to_file(contents, filepath) do + result = + File.write( + filepath, + contents, + [:write] + ) + + case result do + {:error, message} -> Logger.error("Cannot write to #{filepath} #{message}") + _ -> Logger.info("Successfully output logs to #{filepath}") + end + end +end diff --git a/lib/teiserver/mix_tasks/party_balance_stats_types.ex b/lib/teiserver/mix_tasks/party_balance_stats_types.ex new file mode 100644 index 000000000..7ffed20d5 --- /dev/null +++ b/lib/teiserver/mix_tasks/party_balance_stats_types.ex @@ -0,0 +1,10 @@ +defmodule Mix.Tasks.Teiserver.PartyBalanceStatsTypes do + @moduledoc false + # alias Teiserver.Battle.Balance.BalanceTypes, as: BT + + @type balance_result :: %{ + match_id: number(), + parties: [[number()]], + team_players: %{1 => [number()], 2 => [number()]} + } +end diff --git a/lib/teiserver_web/live/account/relationship/index.html.heex b/lib/teiserver_web/live/account/relationship/index.html.heex index f4c507818..f9b2aabe6 100644 --- a/lib/teiserver_web/live/account/relationship/index.html.heex +++ b/lib/teiserver_web/live/account/relationship/index.html.heex @@ -196,7 +196,7 @@
- In addition to the effects of ignoring; if enough players in a lobby are avoiding someone they will not be able to become a player themselves. They will still be able to spectate though. + In addition to the effects of ignoring; if you avoid someone, there's less chance the balance algorithm will place you on the same team. However, avoids will be broken if the algorithm cannot balance teams fairly. This feature is still under development.
<.table id="avoids-table" table_class="table-sm" rows={@avoids}> <:col :let={avoid} label="Name"><%= avoid.to_user.name %> diff --git a/lib/teiserver_web/live/battles/match/show.ex b/lib/teiserver_web/live/battles/match/show.ex index d6c1b7a14..2e5c35267 100644 --- a/lib/teiserver_web/live/battles/match/show.ex +++ b/lib/teiserver_web/live/battles/match/show.ex @@ -137,7 +137,10 @@ defmodule TeiserverWeb.Battle.MatchLive.Show do |> List.flatten() past_balance = - BalanceLib.create_balance(groups, match.team_count, algorithm: algorithm) + BalanceLib.create_balance(groups, match.team_count, + algorithm: algorithm, + debug_mode?: true + ) |> Map.put(:balance_mode, :grouped) # What about new balance? @@ -250,7 +253,7 @@ defmodule TeiserverWeb.Battle.MatchLive.Show do end) |> List.flatten() - BalanceLib.create_balance(groups, match.team_count, algorithm: algorithm) + BalanceLib.create_balance(groups, match.team_count, algorithm: algorithm, debug_mode?: true) |> Map.put(:balance_mode, :grouped) end diff --git a/mix.exs b/mix.exs index 3afa63237..9eb88f4fe 100644 --- a/mix.exs +++ b/mix.exs @@ -90,9 +90,10 @@ defmodule Teiserver.MixProject do {:dialyxir, "~> 1.1", only: [:dev, :test], runtime: false}, {:dart_sass, "~> 0.6", only: [:dev]}, {:libcluster, "~> 3.3"}, - {:tzdata, "~> 1.1"}, + {:tzdata, "~> 1.1.2"}, {:ex_ulid, "~> 0.1.0"}, {:combination, "~> 0.0.3"}, + {:mock, "~> 0.3.0", only: :test}, # Teiserver libs {:openskill, git: "https://github.com/StanczakDominik/openskill.ex.git", branch: "master"}, diff --git a/mix.lock b/mix.lock index 1d1a91b49..3bdb72eac 100644 --- a/mix.lock +++ b/mix.lock @@ -62,10 +62,12 @@ "libring": {:hex, :libring, "1.6.0", "d5dca4bcb1765f862ab59f175b403e356dec493f565670e0bacc4b35e109ce0d", [:mix], [], "hexpm", "5e91ece396af4bce99953d49ee0b02f698cd38326d93cd068361038167484319"}, "logger_file_backend": {:hex, :logger_file_backend, "0.0.13", "df07b14970e9ac1f57362985d76e6f24e3e1ab05c248055b7d223976881977c2", [:mix], [], "hexpm", "71a453a7e6e899ae4549fb147b1c6621f4233f8f48f58ca10a64ec67b6c50018"}, "math": {:hex, :math, "0.6.0", "69325af99e600123f6d994833502f6d063a2fa8f2786a3c0461fe6c6123a5166", [:mix], [], "hexpm", "2c73a64d64f719ee1f2821d382f3ed63e8e9564e5176d1c8aa777aac49b41bf7"}, + "meck": {:hex, :meck, "0.9.2", "85ccbab053f1db86c7ca240e9fc718170ee5bda03810a6292b5306bf31bae5f5", [:rebar3], [], "hexpm", "81344f561357dc40a8344afa53767c32669153355b626ea9fcbc8da6b3045826"}, "merkle_map": {:hex, :merkle_map, "0.2.1", "01a88c87a6b9fb594c67c17ebaf047ee55ffa34e74297aa583ed87148006c4c8", [:mix], [], "hexpm", "fed4d143a5c8166eee4fa2b49564f3c4eace9cb252f0a82c1613bba905b2d04d"}, "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"}, "mime": {:hex, :mime, "1.6.0", "dabde576a497cef4bbdd60aceee8160e02a6c89250d6c0b29e56c0dfb00db3d2", [:mix], [], "hexpm", "31a1a8613f8321143dde1dafc36006a17d28d02bdfecb9e95a880fa7aabd19a7"}, "mimerl": {:hex, :mimerl, "1.3.0", "d0cd9fc04b9061f82490f6581e0128379830e78535e017f7780f37fea7545726", [:rebar3], [], "hexpm", "a1e15a50d1887217de95f0b9b0793e32853f7c258a5cd227650889b38839fe9d"}, + "mock": {:hex, :mock, "0.3.8", "7046a306b71db2488ef54395eeb74df0a7f335a7caca4a3d3875d1fc81c884dd", [:mix], [{:meck, "~> 0.9.2", [hex: :meck, repo: "hexpm", optional: false]}], "hexpm", "7fa82364c97617d79bb7d15571193fc0c4fe5afd0c932cef09426b3ee6fe2022"}, "nostrum": {:hex, :nostrum, "0.8.0", "36f5a08e99c3df3020523be9e1c650ad926a63becc5318562abfe782d586e078", [:mix], [{:certifi, "~> 2.8", [hex: :certifi, repo: "hexpm", optional: false]}, {:gun, "~> 2.0", [hex: :gun, repo: "hexpm", optional: false]}, {:jason, "~> 1.2", [hex: :jason, repo: "hexpm", optional: false]}, {:kcl, "~> 1.4", [hex: :kcl, repo: "hexpm", optional: false]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}], "hexpm", "ce6861391ff346089d32a243fa71c0cb8bff79ab86ad53e8bf72808267899aee"}, "oban": {:hex, :oban, "2.15.2", "8f934a49db39163633965139c8846d8e24c2beb4180f34a005c2c7c3f69a6aa2", [:mix], [{:ecto_sql, "~> 3.6", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:ecto_sqlite3, "~> 0.9", [hex: :ecto_sqlite3, repo: "hexpm", optional: true]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16", [hex: :postgrex, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "0f4a579ea48fc7489e0d84facf8b01566e142bdc6542d7dabce32c10e664f1e9"}, "openskill": {:git, "https://github.com/StanczakDominik/openskill.ex.git", "163a72f7423b8aa964909ea6aa3e9943739d87a6", [branch: "master"]}, diff --git a/test/teiserver/battle/balance_lib_internal_test.exs b/test/teiserver/battle/balance_lib_internal_test.exs index 7fbe0b884..1f0418898 100644 --- a/test/teiserver/battle/balance_lib_internal_test.exs +++ b/test/teiserver/battle/balance_lib_internal_test.exs @@ -108,12 +108,13 @@ defmodule Teiserver.Battle.BalanceLibInternalTest do "brute_force", "force_party", "loser_picks", + "respect_avoids", "split_noobs" ] is_moderator = false result = BalanceLib.get_allowed_algorithms(is_moderator) - assert result == ["default", "auto", "loser_picks", "split_noobs"] + assert result == ["default", "auto", "loser_picks", "respect_avoids", "split_noobs"] end test "Validate result" do diff --git a/test/teiserver/battle/respect_avoids_internal_test.exs b/test/teiserver/battle/respect_avoids_internal_test.exs new file mode 100644 index 000000000..41a193d51 --- /dev/null +++ b/test/teiserver/battle/respect_avoids_internal_test.exs @@ -0,0 +1,37 @@ +defmodule Teiserver.Battle.RespectAvoidsInternalTest do + @moduledoc """ + Can run all balance tests via + mix test --only balance_test + """ + use ExUnit.Case, async: false + + @moduletag :balance_test + alias Teiserver.Battle.Balance.RespectAvoids + + test "can get lobby max avoids" do + player_count = 14 + players_in_parties = 7 + result = RespectAvoids.get_max_avoids(player_count, players_in_parties) + assert result == 3 + + players_in_parties = 6 + result = RespectAvoids.get_max_avoids(player_count, players_in_parties) + assert result == 4 + + players_in_parties = 5 + result = RespectAvoids.get_max_avoids(player_count, players_in_parties) + assert result == 4 + + players_in_parties = 4 + result = RespectAvoids.get_max_avoids(player_count, players_in_parties) + assert result == 5 + + players_in_parties = 2 + result = RespectAvoids.get_max_avoids(player_count, players_in_parties) + assert result == 6 + + players_in_parties = 0 + result = RespectAvoids.get_max_avoids(player_count, players_in_parties) + assert result == 7 + end +end diff --git a/test/teiserver/battle/respect_avoids_test.exs b/test/teiserver/battle/respect_avoids_test.exs new file mode 100644 index 000000000..635c8ef59 --- /dev/null +++ b/test/teiserver/battle/respect_avoids_test.exs @@ -0,0 +1,275 @@ +defmodule Teiserver.Battle.RespectAvoidsTest do + @moduledoc """ + Can run all balance tests via + mix test --only balance_test + """ + use ExUnit.Case, async: false + import Mock + + @moduletag :balance_test + alias Teiserver.Battle.Balance.RespectAvoids + alias Teiserver.Account.RelationshipLib + + test "can process expanded_group" do + # Setup mocks with no avoids (insteading of calling db) + with_mock(RelationshipLib, + get_lobby_avoids: fn _player_ids, _limit, _player_limit, _minimum_time_hours -> [] end, + get_lobby_avoids: fn _player_ids, _limit, _player_limit -> [] end + ) do + # https://server5.beyondallreason.info/battle/2092529/players + expanded_group = [ + %{ + count: 2, + members: [1, 2], + ratings: [12.25, 13.98], + names: ["kyutoryu", "fbots1998"], + uncertainties: [0, 1], + ranks: [1, 1] + }, + %{ + count: 2, + members: ["Dixinormus", "HungDaddy"], + ratings: [18.28, 2.8], + names: ["Dixinormus", "HungDaddy"], + uncertainties: [2, 2], + ranks: [0, 0] + }, + %{ + count: 1, + members: ["SLOPPYGAGGER"], + ratings: [8.89], + names: ["SLOPPYGAGGER"], + uncertainties: [3], + ranks: [2] + }, + %{ + count: 1, + members: ["jauggy"], + ratings: [20.49], + names: ["jauggy"], + uncertainties: [3], + ranks: [2] + }, + %{ + count: 1, + members: ["reddragon2010"], + ratings: [18.4], + names: ["reddragon2010"], + uncertainties: [3], + ranks: [2] + }, + %{ + count: 1, + members: ["Aposis"], + ratings: [20.42], + names: ["Aposis"], + uncertainties: [3], + ranks: [2] + }, + %{ + count: 1, + members: ["MaTThiuS_82"], + ratings: [8.26], + names: ["MaTThiuS_82"], + uncertainties: [3], + ranks: [2] + }, + %{ + count: 1, + members: ["Noody"], + ratings: [17.64], + names: ["Noody"], + uncertainties: [3], + ranks: [2] + }, + %{ + count: 1, + members: ["[DTG]BamBin0"], + ratings: [20.06], + names: ["[DTG]BamBin0"], + uncertainties: [3], + ranks: [2] + }, + %{ + count: 1, + members: ["barmalev"], + ratings: [3.58], + names: ["barmalev"], + uncertainties: [3], + ranks: [2] + } + ] + + result = RespectAvoids.perform(expanded_group, 2) + + assert result.logs == [ + "------------------------------------------------------", + "Algorithm: respect_avoids", + "------------------------------------------------------", + "This algorithm will try and respect parties and avoids of players so long as it can keep team rating difference within certain bounds. Parties have higher importance than avoids.", + "Recent avoids will be ignored. New players will be spread evenly across teams and cannot be avoided.", + "------------------------------------------------------", + "Lobby details:", + "Parties: (kyutoryu, fbots1998), (Dixinormus, HungDaddy)", + "Avoid min time required: 2 h", + "Avoids considered: 0", + "------------------------------------------------------", + "New players: None", + "------------------------------------------------------", + "Perform brute force with the following players to get the best score.", + "Players: Dixinormus, fbots1998, kyutoryu, HungDaddy, jauggy, Aposis, [DTG]BamBin0, reddragon2010, Noody, SLOPPYGAGGER, MaTThiuS_82, barmalev", + "------------------------------------------------------", + "Brute force result:", + "Team rating diff penalty: 0.5", + "Broken party penalty: 0", + "Broken avoid penalty: 0", + "Score: 0.5 (lower is better)", + "------------------------------------------------------", + "Draft remaining players (ordered from best to worst).", + "Remaining: ", + "------------------------------------------------------", + "Final result:", + "Team 1: barmalev, Noody, [DTG]BamBin0, Aposis, HungDaddy, Dixinormus", + "Team 2: MaTThiuS_82, SLOPPYGAGGER, reddragon2010, jauggy, kyutoryu, fbots1998" + ] + end + end + + test "can process expanded_group with parties" do + mock_avoid = [["jauggy", "reddragon2010"]] + # Setup mock with 1 avoid + with_mock(RelationshipLib, + get_lobby_avoids: fn _player_ids, _limit, _player_limit, _minimum_time_hours -> + mock_avoid + end, + get_lobby_avoids: fn _player_ids, _limit, _player_limit -> mock_avoid end + ) do + # https://server5.beyondallreason.info/battle/2092529/players + expanded_group = [ + %{ + count: 2, + members: [1, 2], + ratings: [12.25, 13.98], + names: ["kyutoryu", "fbots1998"], + uncertainties: [0, 1], + ranks: [1, 1] + }, + %{ + count: 1, + members: ["Dixinormus"], + ratings: [18.28], + names: ["Dixinormus"], + uncertainties: [2], + ranks: [0] + }, + %{ + count: 1, + members: ["HungDaddy"], + ratings: [2.8], + names: ["HungDaddy"], + uncertainties: [2], + ranks: [0] + }, + %{ + count: 1, + members: ["SLOPPYGAGGER"], + ratings: [8.89], + names: ["SLOPPYGAGGER"], + uncertainties: [3], + ranks: [2] + }, + %{ + count: 1, + members: ["jauggy"], + ratings: [20.49], + names: ["jauggy"], + uncertainties: [3], + ranks: [2] + }, + %{ + count: 1, + members: ["reddragon2010"], + ratings: [18.4], + names: ["reddragon2010"], + uncertainties: [3], + ranks: [2] + }, + %{ + count: 1, + members: ["Aposis"], + ratings: [20.42], + names: ["Aposis"], + uncertainties: [3], + ranks: [2] + }, + %{ + count: 1, + members: ["MaTThiuS_82"], + ratings: [8.26], + names: ["MaTThiuS_82"], + uncertainties: [3], + ranks: [2] + }, + %{ + count: 1, + members: ["Noody"], + ratings: [17.64], + names: ["Noody"], + uncertainties: [3], + ranks: [2] + }, + %{ + count: 1, + members: ["[DTG]BamBin0"], + ratings: [20.06], + names: ["[DTG]BamBin0"], + uncertainties: [3], + ranks: [2] + }, + %{ + count: 1, + members: ["barmalev"], + ratings: [3.58], + names: ["barmalev"], + uncertainties: [3], + ranks: [2] + } + ] + + result = RespectAvoids.perform(expanded_group, 2) + + assert result.logs == [ + "------------------------------------------------------", + "Algorithm: respect_avoids", + "------------------------------------------------------", + "This algorithm will try and respect parties and avoids of players so long as it can keep team rating difference within certain bounds. Parties have higher importance than avoids.", + "Recent avoids will be ignored. New players will be spread evenly across teams and cannot be avoided.", + "------------------------------------------------------", + "Lobby details:", + "Parties: (kyutoryu, fbots1998)", + "Avoid min time required: 2 h", + "Avoids considered: 1", + "------------------------------------------------------", + "New players: None", + "------------------------------------------------------", + "Perform brute force with the following players to get the best score.", + "Players: fbots1998, kyutoryu, jauggy, reddragon2010, Aposis, [DTG]BamBin0, Dixinormus, Noody, SLOPPYGAGGER, MaTThiuS_82, barmalev, HungDaddy", + "------------------------------------------------------", + "Brute force result:", + "Team rating diff penalty: 0.7", + "Broken party penalty: 0", + "Broken avoid penalty: 0", + "Score: 0.7 (lower is better)", + "------------------------------------------------------", + "Draft remaining players (ordered from best to worst).", + "Remaining: ", + "------------------------------------------------------", + "Final result:", + "Team 1: MaTThiuS_82, SLOPPYGAGGER, Aposis, reddragon2010, kyutoryu, fbots1998", + "Team 2: HungDaddy, barmalev, Noody, Dixinormus, [DTG]BamBin0, jauggy" + ] + + # Notice in result jauggy no longer on same team as reddragon2010 due to avoidance + end + end +end diff --git a/test/teiserver/battle/split_noobs_internal_test.exs b/test/teiserver/battle/split_noobs_internal_test.exs index aa284b63d..1d0ff9fef 100644 --- a/test/teiserver/battle/split_noobs_internal_test.exs +++ b/test/teiserver/battle/split_noobs_internal_test.exs @@ -6,7 +6,6 @@ defmodule Teiserver.Battle.SplitNoobsInternalTest do use ExUnit.Case @moduletag :balance_test alias Teiserver.Battle.Balance.SplitNoobs - alias Teiserver.Battle.BalanceLib test "sort noobs" do noobs = [