diff --git a/lib/teiserver/battle/balance/balance_types.ex b/lib/teiserver/battle/balance/balance_types.ex index 47a1690ca..bafe94a91 100644 --- a/lib/teiserver/battle/balance/balance_types.ex +++ b/lib/teiserver/battle/balance/balance_types.ex @@ -10,7 +10,8 @@ defmodule Teiserver.Battle.Balance.BalanceTypes do names: [String.t()], ranks: [non_neg_integer()], group_rating: rating_value(), - count: non_neg_integer() + count: non_neg_integer(), + uncertainties: [number()] } @type group() :: %{ diff --git a/lib/teiserver/battle/balance/cheeky_switcher_smart.ex b/lib/teiserver/battle/balance/cheeky_switcher_smart.ex index 0f91475b3..8964645ec 100644 --- a/lib/teiserver/battle/balance/cheeky_switcher_smart.ex +++ b/lib/teiserver/battle/balance/cheeky_switcher_smart.ex @@ -20,6 +20,7 @@ defmodule Teiserver.Battle.Balance.CheekySwitcherSmart do # Alias the types alias Teiserver.Battle.BalanceLib alias Teiserver.Battle.Balance.BalanceTypes, as: BT + import Teiserver.Helper.NumberHelper, only: [format: 1] # @type algorithm_state :: %{ # teams: map, @@ -158,7 +159,7 @@ defmodule Teiserver.Battle.Balance.CheekySwitcherSmart do |> Enum.map(fn {group, _} -> group.names |> Enum.with_index() - |> Enum.map(fn {name, i} -> "#{name}[#{Enum.at(group.ratings, i)}]" end) + |> Enum.map(fn {name, i} -> "#{name}[#{format(Enum.at(group.ratings, i))}]" end) end) |> List.flatten() |> Enum.join(",") @@ -168,7 +169,7 @@ defmodule Teiserver.Battle.Balance.CheekySwitcherSmart do |> Enum.map(fn {group, _} -> group.names |> Enum.with_index() - |> Enum.map(fn {name, i} -> "#{name}[#{Enum.at(group.ratings, i)}]" end) + |> Enum.map(fn {name, i} -> "#{name}[#{format(Enum.at(group.ratings, i))}]" end) end) |> List.flatten() |> Enum.join(",") @@ -429,11 +430,11 @@ defmodule Teiserver.Battle.Balance.CheekySwitcherSmart do if next_group.count > 1 do [ - "Group picked #{names |> Enum.join(", ")} for team #{team_key}, adding #{group_rating} points for a new total of #{round(existing_team_rating + group_rating)}" + "Group picked #{names |> Enum.join(", ")} for team #{team_key}, adding #{format(group_rating)} points for a new total of #{round(existing_team_rating + group_rating)}" ] else [ - "Picked #{Enum.at(names, 0)} for team #{team_key}, adding #{group_rating} points for a new total of #{round(existing_team_rating + group_rating)}" + "Picked #{Enum.at(names, 0)} for team #{team_key}, adding #{format(group_rating)} points for a new total of #{round(existing_team_rating + group_rating)}" ] end end diff --git a/lib/teiserver/battle/balance/split_one_chevs.ex b/lib/teiserver/battle/balance/split_one_chevs.ex index 21c0a0342..ecedd8ad7 100644 --- a/lib/teiserver/battle/balance/split_one_chevs.ex +++ b/lib/teiserver/battle/balance/split_one_chevs.ex @@ -1,28 +1,65 @@ defmodule Teiserver.Battle.Balance.SplitOneChevs do @moduledoc """ - This balance algorithm first sorts the users by visible OS (match rating) descending. Then all rank=0 (one chevs) will be placed at the bottom of this sorted list. + Overview: + The goal of this algorithm is to mimic how a human would draft players given the visual information in a lobby. + Humans will generally avoid drafting overrated new players. - Next a team will be chosen to be the picking team. The picking team is the team with the least amount of players. If tied, then the team with the lowest total rating. + Details: + The team with the least amount of players will pick an unchosen player. If there are multiple teams tied for + the lowest player count, then the team with the lowest match rating picks. - Next the picking team will pick the player at the top of the sorted list. + Your team will prefer 3Chev+ players with high OS. If your team must pick a 1-2Chev player, + it will prefer lower uncertainty. - This is repeated until all players are chosen. + This is repeated until all players are chosen. - This algorithm completely ignores parties. + This algorithm completely ignores parties. """ alias Teiserver.Battle.Balance.SplitOneChevsTypes, as: ST alias Teiserver.Battle.Balance.BalanceTypes, as: BT + import Teiserver.Helper.NumberHelper, only: [format: 1] + + @splitter "---------------------------" @doc """ Main entry point used by balance_lib See split_one_chevs_internal_test.exs for sample input """ @spec perform([BT.expanded_group()], non_neg_integer(), list()) :: any() - def perform(expanded_group, team_count, _opts \\ []) do - members = flatten_members(expanded_group) |> sort_members() - %{teams: teams, logs: logs} = assign_teams(members, team_count) - standardise_result(teams, logs) + def perform(expanded_group, team_count, opts \\ []) do + if has_enough_noobs?(expanded_group) do + members = flatten_members(expanded_group) |> sort_members() + %{teams: teams, logs: logs} = assign_teams(members, team_count) + standardise_result(teams, logs) + else + # Not enough noobs; so call another balancer + result = Teiserver.Battle.Balance.LoserPicks.perform(expanded_group, team_count, opts) + + new_logs = + ["Not enough new players; will use another balance algorithm.", @splitter, result.logs] + |> List.flatten() + + Map.put(result, :logs, new_logs) + end + end + + @doc """ + For now this simply checks there is at least a single 1chev or a single 2chev. + However we could modify this in the future to be more complicated e.g. at least a single 1chev + or at least two, 2chevs. + """ + @spec has_enough_noobs?([BT.expanded_group()]) :: bool() + def has_enough_noobs?(expanded_group) do + ranks = + Enum.map(expanded_group, fn x -> + Map.get(x, :ranks, []) + end) + |> List.flatten() + + Enum.any?(ranks, fn x -> + x < 2 + end) end @doc """ @@ -30,24 +67,38 @@ defmodule Teiserver.Battle.Balance.SplitOneChevs do See split_one_chevs_internal_test.exs for sample input """ def flatten_members(expanded_group) do - for %{members: members, ratings: ratings, ranks: ranks, names: names} <- expanded_group, + for %{ + members: members, + ratings: ratings, + ranks: ranks, + names: names, + uncertainties: uncertainties + } <- expanded_group, # Zipping will create binary tuples from 2 lists - {id, rating, rank, name} <- Enum.zip([members, ratings, ranks, names]), + {id, rating, rank, name, uncertainty} <- + Enum.zip([members, ratings, ranks, names, uncertainties]), # Create result value - do: %{member_id: id, rating: rating, rank: rank, name: name} + do: %{ + member_id: id, + rating: rating, + rank: rank, + name: name, + uncertainty: uncertainty + } end @doc """ - Sorts members by rating but puts one chevs at the bottom - See split_one_chevs_internal_test.exs for sample input + Experienced players will be on top followed by noobs. + Experienced players are 3+ Chevs. They will be sorted with higher OS on top. + Noobs are 1-2 Chevs. They will be sorted with lower uncertainty on top. """ def sort_members(members) do - non_noobs = Enum.filter(members, fn x -> x.rank != 0 end) - noobs = Enum.filter(members, fn x -> x.rank == 0 end) + non_noobs = Enum.filter(members, fn x -> x.rank >= 2 end) + noobs = Enum.filter(members, fn x -> x.rank < 2 end) [ Enum.sort_by(non_noobs, fn x -> x.rating end, &>=/2), - Enum.sort_by(noobs, fn x -> x.rating end, &>=/2) + Enum.sort_by(noobs, fn x -> x.uncertainty end, &<=/2) ] |> List.flatten() end @@ -59,14 +110,21 @@ defmodule Teiserver.Battle.Balance.SplitOneChevs do def assign_teams(member_list, number_of_teams) do default_acc = %{ teams: create_empty_teams(number_of_teams), - logs: ["Begin split_one_chevs balance"] + logs: [ + "Algorithm: split_one_chevs", + @splitter, + "Your team will try and pick 3Chev+ players first, with preference for higher OS. If 1-2Chevs are the only remaining players, then lower uncertainty is preferred.", + @splitter + ] } Enum.reduce(member_list, default_acc, fn x, acc -> picking_team = get_picking_team(acc.teams) update_picking_team = Map.merge(picking_team, %{members: [x | picking_team.members]}) username = x.name - new_log = "#{username} (Chev: #{x.rank + 1}) picked for Team #{picking_team.team_id}" + + new_log = + "#{username} (#{format(x.rating)}, σ: #{format(x.uncertainty)}, Chev: #{x.rank + 1}) picked for Team #{picking_team.team_id}" %{ teams: [update_picking_team | get_non_picking_teams(acc.teams, picking_team)], diff --git a/lib/teiserver/battle/libs/balance_lib.ex b/lib/teiserver/battle/libs/balance_lib.ex index 3a7b760d7..b3d11f512 100644 --- a/lib/teiserver/battle/libs/balance_lib.ex +++ b/lib/teiserver/battle/libs/balance_lib.ex @@ -27,6 +27,8 @@ defmodule Teiserver.Battle.BalanceLib do # which one will get to pick first @shuffle_first_pick true + @default_balance_algorithm "loser_picks" + @spec defaults() :: map() def defaults() do %{ @@ -40,6 +42,11 @@ defmodule Teiserver.Battle.BalanceLib do } end + def get_default_algorithm() do + # For now it's a constant but this could be moved to a configurable value + @default_balance_algorithm + end + @spec algorithm_modules() :: %{String.t() => module} def algorithm_modules() do %{ @@ -53,6 +60,7 @@ defmodule Teiserver.Battle.BalanceLib do @doc """ Teifion only allowed force_party to be used by mods because it led to noob-stomping unbalanced teams """ + @spec get_allowed_algorithms(boolean()) :: [String.t()] def get_allowed_algorithms(is_moderator) do if(is_moderator) do Teiserver.Battle.BalanceLib.algorithm_modules() |> Map.keys() @@ -81,6 +89,12 @@ defmodule Teiserver.Battle.BalanceLib do mean_diff_max: the maximum difference in mean between the party and paired parties stddev_diff_max: the maximum difference in stddev between the party and paired parties """ + @spec create_balance([BT.player_group()], non_neg_integer) :: map + def create_balance(groups, team_count) do + # This method sets default opts (and makes some warnings go away) + create_balance(groups, team_count, []) + end + @spec create_balance([BT.player_group()], non_neg_integer, list) :: map def create_balance([], _team_count, _opts) do %{ @@ -93,7 +107,8 @@ defmodule Teiserver.Battle.BalanceLib do team_players: %{}, team_sizes: %{}, means: %{}, - stdevs: %{} + stdevs: %{}, + has_parties?: false } end @@ -118,6 +133,15 @@ defmodule Teiserver.Battle.BalanceLib do end end) + uncertainties = + Map.values(members) + |> Enum.map(fn x -> + cond do + Map.has_key?(x, :uncertainty) -> x.uncertainty + true -> 0 + end + end) + names = members |> Enum.map(fn {id, details} -> @@ -133,15 +157,18 @@ defmodule Teiserver.Battle.BalanceLib do ranks: ranks, names: names, group_rating: Enum.sum(ratings), - count: Enum.count(ratings) + count: Enum.count(ratings), + uncertainties: uncertainties } end) + algo_name = opts[:algorithm] || get_default_algorithm() + # Now we pass this to the algorithm and it does the rest! balance_result = - case algorithm_modules()[opts[:algorithm] || "loser_picks"] do + case algorithm_modules()[algo_name] do nil -> - raise "No balance module by the name of '#{opts[:algorithm] || "loser_picks"}'" + raise "No balance module by the name of '#{algo_name}'" m -> m.perform(expanded_groups, team_count, opts) @@ -164,9 +191,16 @@ defmodule Teiserver.Battle.BalanceLib do |> Enum.map(fn group -> # Iterate over our map Map.new(group, fn {user_id, value} -> - cond do - is_number(value) -> {user_id, get_user_rating_rank_old(user_id, value)} - true -> {user_id, value} + case value do + x when is_number(x) -> + {user_id, get_user_rating_rank_old(user_id, x)} + + # match_controller will use this condition when balancing using old data + %{"rating_value" => rating_value, "uncertainty" => uncertainty} -> + {user_id, get_user_rating_rank_old(user_id, rating_value, uncertainty)} + + _ -> + {user_id, value} end end) end) @@ -176,7 +210,7 @@ defmodule Teiserver.Battle.BalanceLib do defp cleanup_result(result) do Map.take( result, - ~w(team_groups team_players ratings captains team_sizes deviation means stdevs logs)a + ~w(team_groups team_players ratings captains team_sizes deviation means stdevs logs has_parties?)a ) end @@ -221,7 +255,8 @@ defmodule Teiserver.Battle.BalanceLib do Map.merge(balance_result, %{ team_groups: team_groups, - team_players: team_players + team_players: team_players, + has_parties?: balanced_teams_has_parties?(team_groups) }) end @@ -560,17 +595,10 @@ defmodule Teiserver.Battle.BalanceLib do get_user_rating_value(userid, rating_type_id) end - # Used to get the rating value of the user for internal balance purposes which might be - # different from public/reporting @spec get_user_balance_rating_value(T.userid(), String.t() | non_neg_integer()) :: BT.rating_value() defp get_user_balance_rating_value(userid, rating_type_id) when is_integer(rating_type_id) do - real_rating = get_user_rating_value(userid, rating_type_id) - - stats = Account.get_user_stat_data(userid) - adjustment = int_parse(stats["os_global_adjust"]) - - real_rating + adjustment + get_user_rating_value(userid, rating_type_id) end defp get_user_balance_rating_value(_userid, nil), do: nil @@ -586,26 +614,39 @@ defmodule Teiserver.Battle.BalanceLib do def get_user_rating_rank(userid, rating_type, fuzz_multiplier) do # This call will go to db or cache - # The cache for ratings is :teiserver_user_stat_cache + # The cache for ratings is :teiserver_user_ratings # which has an expiry of 60s # See application.ex for cache settings rating_type_id = MatchRatingLib.rating_type_name_lookup()[rating_type] - rating = get_user_balance_rating_value(userid, rating_type_id) + {skill, uncertainty} = get_user_rating_value_uncertainty_pair(userid, rating_type_id) + rating = calculate_rating_value(skill, uncertainty) rating = fuzz_rating(rating, fuzz_multiplier) + + # Get stats data + # Potentially adjust ratings based on os_global_adjust + stats_data = Account.get_user_stat_data(userid) + adjustment = int_parse(stats_data["os_global_adjust"]) + rating = rating + adjustment + rank = Map.get(stats_data, "rank", 0) + # This call will go to db or cache # The cache for users is :users # which is permanent (and would be instantiated on login) # See application.ex for cache settings - %{rank: rank, name: name} = Account.get_user_by_id(userid) - %{rating: rating, rank: rank, name: name} + + %{name: name} = Account.get_user_by_id(userid) + %{rating: rating, rank: rank, name: name, uncertainty: uncertainty} end @doc """ This is used by some screens to calculate a theoretical balance based on old ratings """ - def get_user_rating_rank_old(userid, rating_value) do - %{rank: rank, name: name} = Account.get_user_by_id(userid) - %{rating: rating_value, rank: rank, name: name} + def get_user_rating_rank_old(userid, rating_value, uncertainty \\ 0) do + stats_data = Account.get_user_stat_data(userid) + rank = Map.get(stats_data, "rank", 0) + + %{name: name} = Account.get_user_by_id(userid) + %{rating: rating_value, rank: rank, name: name, uncertainty: uncertainty} end defp fuzz_rating(rating, multiplier) do @@ -837,4 +878,30 @@ defmodule Teiserver.Battle.BalanceLib do for(y <- make_combinations(n - x.count, xs), do: [x | y]) ++ make_combinations(n, xs) end end + + @doc """ + Can be called to detect if a balance result has parties + If the result has no parties we do not need to check team deviation + """ + def balanced_teams_has_parties?(team_groups) do + Enum.reduce_while(team_groups, false, fn {_key, team}, _acc -> + case team_has_parties?(team) do + true -> {:halt, true} + false -> {:cont, false} + end + end) + end + + @spec team_has_parties?([BT.group()]) :: boolean() + def team_has_parties?(team) do + Enum.reduce_while(team, false, fn x, _acc -> + group_count = x[:count] + + if group_count > 1 do + {:halt, true} + else + {:cont, false} + end + end) + end end diff --git a/lib/teiserver/data/cache_user.ex b/lib/teiserver/data/cache_user.ex index 6bdd16fd1..47f9cef4f 100644 --- a/lib/teiserver/data/cache_user.ex +++ b/lib/teiserver/data/cache_user.ex @@ -1277,7 +1277,9 @@ defmodule Teiserver.CacheUser do ingame_minutes = (stats.data["player_minutes"] || 0) + (stats.data["spectator_minutes"] || 0) * 0.5 - round(ingame_minutes / 60) + # Hours are rounded down which helps to determine if a user has hit a + # chevron hours threshold. So a user with 4.9 hours is still chevron 1 or rank 0 + trunc(ingame_minutes / 60) end # Based on actual ingame time @@ -1310,14 +1312,16 @@ defmodule Teiserver.CacheUser do def calculate_rank(userid, "Role") do ingame_hours = rank_time(userid) + # Thresholds should match what is on the website: + # https://www.beyondallreason.info/guide/rating-and-lobby-balance#rank-icons cond do has_any_role?(userid, ["Tournament winner"]) -> 7 has_any_role?(userid, ~w(Core Contributor)) -> 6 - ingame_hours > 1000 -> 5 - ingame_hours > 250 -> 4 - ingame_hours > 100 -> 3 - ingame_hours > 15 -> 2 - ingame_hours > 5 -> 1 + ingame_hours >= 1000 -> 5 + ingame_hours >= 250 -> 4 + ingame_hours >= 100 -> 3 + ingame_hours >= 15 -> 2 + ingame_hours >= 5 -> 1 true -> 0 end end diff --git a/lib/teiserver/game/servers/balancer_server.ex b/lib/teiserver/game/servers/balancer_server.ex index c35913e15..4b408e8d9 100644 --- a/lib/teiserver/game/servers/balancer_server.ex +++ b/lib/teiserver/game/servers/balancer_server.ex @@ -7,6 +7,8 @@ defmodule Teiserver.Game.BalancerServer do alias Teiserver.Battle.MatchLib @tick_interval 2_000 + # Balance algos that allow fuzz; randomness will be added to match rating before processing + @algos_allowing_fuzz ~w(loser_picks force_party) @spec start_link(List.t()) :: :ignore | {:error, any} | {:ok, pid} def start_link(opts) do @@ -175,8 +177,9 @@ defmodule Teiserver.Game.BalancerServer do if opts[:allow_groups] do party_result = make_grouped_balance(team_count, players, game_type, opts) + has_parties? = Map.get(party_result, :has_parties?, true) - if party_result.deviation > opts[:max_deviation] do + if has_parties? && party_result.deviation > opts[:max_deviation] do make_solo_balance( team_count, players, @@ -210,14 +213,16 @@ defmodule Teiserver.Game.BalancerServer do player_id_list |> Enum.map(fn userid -> %{ - userid => BalanceLib.get_user_rating_rank(userid, game_type, opts[:fuzz_multiplier]) + userid => + BalanceLib.get_user_rating_rank(userid, game_type, get_fuzz_multiplier(opts)) } end) {_party_id, player_id_list} -> player_id_list |> Map.new(fn userid -> - {userid, BalanceLib.get_user_rating_rank(userid, game_type, opts[:fuzz_multiplier])} + {userid, + BalanceLib.get_user_rating_rank(userid, game_type, get_fuzz_multiplier(opts))} end) end) |> List.flatten() @@ -233,7 +238,7 @@ defmodule Teiserver.Game.BalancerServer do players |> Enum.map(fn %{userid: userid} -> %{ - userid => BalanceLib.get_user_rating_rank(userid, game_type, opts[:fuzz_multiplier]) + userid => BalanceLib.get_user_rating_rank(userid, game_type, get_fuzz_multiplier(opts)) } end) @@ -246,6 +251,15 @@ defmodule Teiserver.Game.BalancerServer do }) end + def get_fuzz_multiplier(opts) do + algo = opts[:algorithm] + + case Enum.member?(@algos_allowing_fuzz, algo) do + true -> opts[:fuzz_multiplier] + false -> 0 + end + end + @spec empty_state(T.lobby_id()) :: T.balance_server_state() defp empty_state(lobby_id) do # it's possible the lobby is nil before we even get to start this up (tests in particular) diff --git a/lib/teiserver/helpers/number_helper.ex b/lib/teiserver/helpers/number_helper.ex index 800eb194a..0c6bfd023 100644 --- a/lib/teiserver/helpers/number_helper.ex +++ b/lib/teiserver/helpers/number_helper.ex @@ -120,4 +120,20 @@ defmodule Teiserver.Helper.NumberHelper do def percent(v, dp) do round(v * 100, dp) end + + @doc """ + Use this function for printing floats with only one decimal place. + Integers will be returned without modification. + If you need to always print the same amount of decimals no matter if float or integer, + then use the round function also defined in this module. + """ + def format(rating_value) when is_float(rating_value) do + rating_value + |> Decimal.from_float() + |> Decimal.round(1) + end + + def format(rating_value) when is_integer(rating_value) do + rating_value + end end diff --git a/lib/teiserver/mix_tasks/fake_data.ex b/lib/teiserver/mix_tasks/fake_data.ex index 1b322b40b..a6f2a4d97 100644 --- a/lib/teiserver/mix_tasks/fake_data.ex +++ b/lib/teiserver/mix_tasks/fake_data.ex @@ -34,6 +34,9 @@ defmodule Mix.Tasks.Teiserver.Fakedata do make_moderation() make_one_time_code() + # Add fake playtime data to all our non-bot users + Mix.Task.run("teiserver.fake_playtime") + :timer.sleep(50) IO.puts( @@ -96,7 +99,7 @@ defmodule Mix.Tasks.Teiserver.Fakedata do name: generate_throwaway_name() |> String.replace(" ", ""), email: UUID.uuid1(), password: root_user.password, - permissions: ["admin.dev.developer"], + permissions: [], icon: "fa-solid #{StylingHelper.random_icon()}", colour: StylingHelper.random_colour(), trust_score: 10_000, diff --git a/lib/teiserver/mix_tasks/fake_playtime.ex b/lib/teiserver/mix_tasks/fake_playtime.ex new file mode 100644 index 000000000..f07cbf66a --- /dev/null +++ b/lib/teiserver/mix_tasks/fake_playtime.ex @@ -0,0 +1,68 @@ +defmodule Mix.Tasks.Teiserver.FakePlaytime do + @moduledoc """ + Adds fake play time stats to all non bot users + This will also be called by the teiserver.fakedata task + + If you want to run this task invidually, use: + mix teiserver.fake_playtime + """ + + use Mix.Task + require Logger + alias Teiserver.{Account, CacheUser} + + def run(_args) do + Application.ensure_all_started(:teiserver) + + if Application.get_env(:teiserver, Teiserver)[:enable_hailstorm] do + Account.list_users( + search: [ + not_has_role: "Bot" + ], + select: [:id, :name] + ) + |> Enum.map(fn user -> + update_stats(user.id, random_playtime()) + end) + + Logger.info("Finished applying fake playtime data") + end + end + + def update_stats(user_id, player_minutes) do + Account.update_user_stat(user_id, %{ + player_minutes: player_minutes, + total_minutes: player_minutes + }) + + # Now recalculate ranks + # This calc would usually be done in do_login + rank = CacheUser.calculate_rank(user_id) + + Account.update_user_stat(user_id, %{ + rank: rank + }) + end + + defp random_playtime() do + hours = + case get_player_experience() do + :just_installed -> Enum.random(0..4) + :beginner -> Enum.random(5..99) + :average -> Enum.random(100..249) + :pro -> Enum.random(250..1750) + end + + hours * 60 + end + + @spec get_player_experience() :: :just_installed | :beginner | :average | :pro + defp get_player_experience do + case Enum.random(0..3) do + 0 -> :just_installed + 1 -> :beginner + 2 -> :average + 3 -> :pro + end + end +end diff --git a/lib/teiserver_web/live/battles/match/ratings.html.heex b/lib/teiserver_web/live/battles/match/ratings.html.heex index ca93328ec..4c750f485 100644 --- a/lib/teiserver_web/live/battles/match/ratings.html.heex +++ b/lib/teiserver_web/live/battles/match/ratings.html.heex @@ -89,7 +89,9 @@ end %> <%= if log.match do %> - <%= log.match.map %> + + <%= live_redirect("#{log.match.map}", to: ~p"/battle/#{log.match_id}") %> + <%= log.match.team_size * log.match.team_count %> <% else %> <%= log.value["reason"] || "No match" %> diff --git a/lib/teiserver_web/live/battles/match/show.ex b/lib/teiserver_web/live/battles/match/show.ex index bd02fcf6b..09ff0660c 100644 --- a/lib/teiserver_web/live/battles/match/show.ex +++ b/lib/teiserver_web/live/battles/match/show.ex @@ -1,7 +1,7 @@ defmodule TeiserverWeb.Battle.MatchLive.Show do @moduledoc false use TeiserverWeb, :live_view - alias Teiserver.{Battle, Game, Account, Telemetry} + alias Teiserver.{Battle, Game, Telemetry} alias Teiserver.Battle.{MatchLib, BalanceLib} @impl true @@ -11,6 +11,11 @@ defmodule TeiserverWeb.Battle.MatchLive.Show do |> assign(:site_menu_active, "match") |> assign(:view_colour, Teiserver.Battle.MatchLib.colours()) |> assign(:tab, "details") + |> assign( + :algorithm_options, + BalanceLib.get_allowed_algorithms(true) + ) + |> assign(:algorithm, BalanceLib.get_default_algorithm()) {:ok, socket} end @@ -49,8 +54,10 @@ defmodule TeiserverWeb.Battle.MatchLive.Show do end defp apply_action(%{assigns: %{match_name: match_name}} = socket, :balance, _params) do + # Restrict the balance tab to certain roles. + # Note that Staff roles like "Tester" also contain Contributor role. socket - |> mount_require_any(["Reviewer"]) + |> mount_require_any(["Reviewer", "Contributor"]) |> assign(:page_title, "#{match_name} - Balance") end @@ -59,7 +66,9 @@ defmodule TeiserverWeb.Battle.MatchLive.Show do # {:noreply, assign(socket, :tab, tab)} # end - defp get_match(%{assigns: %{id: id, current_user: _current_user}} = socket) do + defp get_match( + %{assigns: %{id: id, algorithm: algorithm, current_user: _current_user}} = socket + ) do if connected?(socket) do match = Battle.get_match!(id, @@ -115,24 +124,24 @@ defmodule TeiserverWeb.Battle.MatchLive.Show do player_id_list |> Enum.filter(fn userid -> rating_logs[userid] != nil end) |> Enum.map(fn userid -> - %{userid => rating_logs[userid].value["rating_value"]} + %{userid => rating_logs[userid].value} end) {_party_id, player_id_list} -> player_id_list |> Enum.filter(fn userid -> rating_logs[userid] != nil end) |> Map.new(fn userid -> - {userid, rating_logs[userid].value["rating_value"]} + {userid, rating_logs[userid].value} end) end) |> List.flatten() past_balance = - BalanceLib.create_balance(groups, match.team_count, mode: :loser_picks) + BalanceLib.create_balance(groups, match.team_count, algorithm: algorithm) |> Map.put(:balance_mode, :grouped) # What about new balance? - new_balance = generate_new_balance_data(match) + new_balance = generate_new_balance_data(match, algorithm) raw_events = Telemetry.list_simple_match_events(where: [match_id: match.id], preload: [:event_types]) @@ -197,7 +206,7 @@ defmodule TeiserverWeb.Battle.MatchLive.Show do end end - defp generate_new_balance_data(match) do + defp generate_new_balance_data(match, algorithm) do rating_type = MatchLib.game_type(match.team_size, match.team_count) partied_players = @@ -223,7 +232,21 @@ defmodule TeiserverWeb.Battle.MatchLive.Show do end) |> List.flatten() - BalanceLib.create_balance(groups, match.team_count, mode: :loser_picks) + BalanceLib.create_balance(groups, match.team_count, algorithm: algorithm) |> Map.put(:balance_mode, :grouped) end + + @doc """ + Handles the dropdown for algorithm changing + """ + @impl true + def handle_event("update-algorithm", event, socket) do + [key] = event["_target"] + value = event[key] + + {:noreply, + socket + |> assign(:algorithm, value) + |> get_match()} + end end diff --git a/lib/teiserver_web/live/battles/match/show.html.heex b/lib/teiserver_web/live/battles/match/show.html.heex index a38abbee4..6cf6094b0 100644 --- a/lib/teiserver_web/live/battles/match/show.html.heex +++ b/lib/teiserver_web/live/battles/match/show.html.heex @@ -41,7 +41,7 @@ <% end %> - <%= if @rating_logs != %{} and allow?(@current_user, "Overwatch") do %> + <%= if @rating_logs != %{} and allow_any?(@current_user, ["Overwatch", "Contributor"]) do %> <.tab_nav url={~p"/battle/#{@match.id}/balance"} selected={@tab == :balance}> Balance @@ -282,9 +282,22 @@ <% end %> - <%= if allow?(@current_user, "Overwatch") do %> + <%= if allow_any?(@current_user, ["Overwatch", "Contributor"]) do %>
+
+ <.input + type="select" + label="Balance Algorithm" + options={@algorithm_options} + name="algorithm" + value={@algorithm} + phx-change="update-algorithm" + /> +
+
+

Based on data at the time

+ diff --git a/mix.exs b/mix.exs index a8da2ba68..2253c9279 100644 --- a/mix.exs +++ b/mix.exs @@ -132,7 +132,7 @@ defmodule Teiserver.MixProject do [ plt_core_path: "priv/plts", plt_file: {:no_warn, "priv/plts/dialyzer.plt"}, - plt_add_apps: [:ex_unit] + plt_add_apps: [:ex_unit, :mix] ] end diff --git a/test/teiserver/battle/balance_lib_internal_test.exs b/test/teiserver/battle/balance_lib_internal_test.exs index 9a6343640..ea0c1be4a 100644 --- a/test/teiserver/battle/balance_lib_internal_test.exs +++ b/test/teiserver/battle/balance_lib_internal_test.exs @@ -21,12 +21,12 @@ defmodule Teiserver.Battle.BalanceLibInternalTest do assert fixed_groups == [ %{ - user1.id => %{name: user1.name, rank: 0, rating: 19}, - user2.id => %{name: user2.name, rank: 0, rating: 20} + user1.id => %{name: user1.name, rank: 0, rating: 19, uncertainty: 0}, + user2.id => %{name: user2.name, rank: 0, rating: 20, uncertainty: 0} }, - %{user3.id => %{name: user3.name, rank: 0, rating: 18}}, - %{user4.id => %{name: user4.name, rank: 0, rating: 15}}, - %{user5.id => %{name: user5.name, rank: 0, rating: 11}} + %{user3.id => %{name: user3.name, rank: 0, rating: 18, uncertainty: 0}}, + %{user4.id => %{name: user4.name, rank: 0, rating: 15, uncertainty: 0}}, + %{user5.id => %{name: user5.name, rank: 0, rating: 11, uncertainty: 0}} ] # loser_picks algo will hit the databases so let's just test with split_one_chevs @@ -64,6 +64,59 @@ defmodule Teiserver.Battle.BalanceLibInternalTest do assert result != nil end + test "does team have parties" do + team = [ + %{count: 2, group_rating: 13, members: [1, 4], ratings: [8, 5]} + ] + + assert BalanceLib.team_has_parties?(team) + + team = [ + %{count: 1, group_rating: 8, members: [2], ratings: [8]} + ] + + refute BalanceLib.team_has_parties?(team) + end + + test "does team_groups in balance result have parties" do + team_groups = %{ + 1 => [ + %{count: 2, group_rating: 13, members: [1, 4], ratings: [8, 5]} + ], + 2 => [ + %{count: 1, group_rating: 6, members: [2], ratings: [6]} + ] + } + + assert BalanceLib.balanced_teams_has_parties?(team_groups) + + team_groups = %{ + 1 => [ + %{count: 1, group_rating: 8, members: [1], ratings: [8]}, + %{count: 1, group_rating: 8, members: [2], ratings: [8]} + ], + 2 => [ + %{count: 1, group_rating: 6, members: [3], ratings: [6]}, + %{count: 1, group_rating: 8, members: [4], ratings: [8]} + ] + } + + refute BalanceLib.balanced_teams_has_parties?(team_groups) + + team_groups = %{ + 1 => [ + %{count: 1, group_rating: 8, members: [1], ratings: [8]}, + %{count: 1, group_rating: 8, members: [2], ratings: [8]} + ], + 2 => [ + %{count: 1, group_rating: 13, members: [3], ratings: [6]}, + %{count: 2, group_rating: 8, members: [4, 5], ratings: [8, 0]} + ] + } + + assert BalanceLib.balanced_teams_has_parties?(team_groups) + end + defp create_test_users do Enum.map(1..5, fn k -> Teiserver.TeiserverTestLib.new_user("User_#{k}") diff --git a/test/teiserver/battle/balance_lib_test.exs b/test/teiserver/battle/balance_lib_test.exs index e7782f7d3..a60e54eeb 100644 --- a/test/teiserver/battle/balance_lib_test.exs +++ b/test/teiserver/battle/balance_lib_test.exs @@ -1,7 +1,7 @@ defmodule Teiserver.Battle.BalanceLibTest do @moduledoc """ - Can run tests in this file only by - mix test test/teiserver/battle/balance_lib_test.exs + Can run all balance tests via + mix test --only balance_test """ use Teiserver.DataCase, async: true @moduletag :balance_test @@ -20,6 +20,7 @@ defmodule Teiserver.Battle.BalanceLibTest do ) assert result != nil + refute Map.get(result, :has_parties?, true) end) end @@ -38,6 +39,7 @@ defmodule Teiserver.Battle.BalanceLibTest do ) assert result != nil + refute Map.get(result, :has_parties?, true) end) end @@ -59,6 +61,7 @@ defmodule Teiserver.Battle.BalanceLibTest do ) assert result != nil + refute Map.get(result, :has_parties?, true) end) end @@ -80,6 +83,7 @@ defmodule Teiserver.Battle.BalanceLibTest do ) assert result != nil + refute Map.get(result, :has_parties?, true) end) end @@ -103,6 +107,7 @@ defmodule Teiserver.Battle.BalanceLibTest do ) assert result != nil + refute Map.get(result, :has_parties?, true) end) end @@ -139,6 +144,9 @@ defmodule Teiserver.Battle.BalanceLibTest do ) assert result != nil + # has_parties? might be true/false depending on the algorithm. + # Just check that the key exists + assert Map.has_key?(result, :has_parties?) end) end end diff --git a/test/teiserver/battle/cheeky_switcher_smart_balance_test.exs b/test/teiserver/battle/cheeky_switcher_smart_balance_test.exs index ef2069520..75cbabff4 100644 --- a/test/teiserver/battle/cheeky_switcher_smart_balance_test.exs +++ b/test/teiserver/battle/cheeky_switcher_smart_balance_test.exs @@ -1,7 +1,7 @@ defmodule Teiserver.Battle.CheekySwitcherSmartBalanceTest do @moduledoc """ - Can run tests in this file only by - mix test test/teiserver/battle/cheeky_switcher_smart_balance_test.exs + Can run all balance tests via + mix test --only balance_test """ use Teiserver.DataCase, async: true @moduletag :balance_test @@ -53,7 +53,8 @@ defmodule Teiserver.Battle.CheekySwitcherSmartBalanceTest do }, deviation: 0, means: %{1 => 6.5, 2 => 6.5}, - stdevs: %{1 => 0.5, 2 => 1.5} + stdevs: %{1 => 0.5, 2 => 1.5}, + has_parties?: false } end diff --git a/test/teiserver/battle/loser_picks_balance_test.exs b/test/teiserver/battle/loser_picks_balance_test.exs index 52150e059..95ca2f5ef 100644 --- a/test/teiserver/battle/loser_picks_balance_test.exs +++ b/test/teiserver/battle/loser_picks_balance_test.exs @@ -1,7 +1,7 @@ defmodule Teiserver.Battle.LoserPicksBalanceTest do @moduledoc """ - Can run tests in this file only by - mix test test/teiserver/battle/loser_picks_balance_test.exs + Can run all balance tests via + mix test --only balance_test """ use Teiserver.DataCase, async: true @moduletag :balance_test @@ -52,7 +52,8 @@ defmodule Teiserver.Battle.LoserPicksBalanceTest do }, deviation: 0, means: %{1 => 6.5, 2 => 6.5}, - stdevs: %{1 => 1.5, 2 => 0.5} + stdevs: %{1 => 1.5, 2 => 0.5}, + has_parties?: false } end @@ -103,7 +104,8 @@ defmodule Teiserver.Battle.LoserPicksBalanceTest do }, deviation: 13, means: %{1 => 8.0, 2 => 7.0, 3 => 6.0, 4 => 5.0}, - stdevs: %{1 => 0.0, 2 => 0.0, 3 => 0.0, 4 => 0.0} + stdevs: %{1 => 0.0, 2 => 0.0, 3 => 0.0, 4 => 0.0}, + has_parties?: false } end @@ -160,7 +162,8 @@ defmodule Teiserver.Battle.LoserPicksBalanceTest do }, deviation: 0, means: %{1 => 7.5, 2 => 7.0, 3 => 7.5}, - stdevs: %{1 => 1.5, 2 => 2.0, 3 => 0.5} + stdevs: %{1 => 1.5, 2 => 2.0, 3 => 0.5}, + has_parties?: false } end @@ -209,7 +212,8 @@ defmodule Teiserver.Battle.LoserPicksBalanceTest do }, deviation: 0, means: %{1 => 6.5, 2 => 6.5}, - stdevs: %{1 => 1.5, 2 => 0.5} + stdevs: %{1 => 1.5, 2 => 0.5}, + has_parties?: true } end @@ -276,7 +280,8 @@ defmodule Teiserver.Battle.LoserPicksBalanceTest do }, team_sizes: %{1 => 8, 2 => 8}, means: %{1 => 20.125, 2 => 20.5}, - stdevs: %{1 => 9.29297449689818, 2 => 8.671072598012312} + stdevs: %{1 => 9.29297449689818, 2 => 8.671072598012312}, + has_parties?: true } end @@ -335,7 +340,8 @@ defmodule Teiserver.Battle.LoserPicksBalanceTest do team_players: %{1 => 'ejlnprft', 2 => 'hikmoqsg'}, team_sizes: %{1 => 8, 2 => 8}, means: %{1 => 23.0, 2 => 23.0}, - stdevs: %{1 => 12.816005617976296, 2 => 8.674675786448736} + stdevs: %{1 => 12.816005617976296, 2 => 8.674675786448736}, + has_parties?: false } end @@ -394,7 +400,8 @@ defmodule Teiserver.Battle.LoserPicksBalanceTest do }, team_sizes: %{1 => 8, 2 => 8}, means: %{1 => 31.0, 2 => 31.625}, - stdevs: %{1 => 16.015617378046965, 2 => 15.090870584562046} + stdevs: %{1 => 16.015617378046965, 2 => 15.090870584562046}, + has_parties?: true } result2 = @@ -453,7 +460,8 @@ defmodule Teiserver.Battle.LoserPicksBalanceTest do }, team_sizes: %{1 => 8, 2 => 8}, means: %{1 => 31.0, 2 => 31.625}, - stdevs: %{1 => 16.0312195418814, 2 => 15.074295174236173} + stdevs: %{1 => 16.0312195418814, 2 => 15.074295174236173}, + has_parties?: true } end end diff --git a/test/teiserver/battle/split_one_chevs_internal_test.exs b/test/teiserver/battle/split_one_chevs_internal_test.exs index dc5e9fc28..9ff7f975b 100644 --- a/test/teiserver/battle/split_one_chevs_internal_test.exs +++ b/test/teiserver/battle/split_one_chevs_internal_test.exs @@ -1,8 +1,8 @@ defmodule Teiserver.Battle.SplitOneChevsInternalTest do @moduledoc """ - This tests the internal functions of SplitOneChevs - Can run tests in this file only by - mix test test/teiserver/battle/split_one_chevs_internal_test.exs + This tests the internal functions of SplitOneChevs + Can run all balance tests via + mix test --only balance_test """ use ExUnit.Case @moduletag :balance_test @@ -16,7 +16,8 @@ defmodule Teiserver.Battle.SplitOneChevsInternalTest do group_rating: 13, ratings: [8, 5], ranks: [1, 0], - names: ["Pro1", "Noob1"] + names: ["Pro1", "Noob1"], + uncertainties: [0, 1] }, %{ count: 1, @@ -24,7 +25,8 @@ defmodule Teiserver.Battle.SplitOneChevsInternalTest do group_rating: 6, ratings: [6], ranks: [0], - names: ["Noob2"] + names: ["Noob2"], + uncertainties: [2] }, %{ count: 1, @@ -32,7 +34,8 @@ defmodule Teiserver.Battle.SplitOneChevsInternalTest do group_rating: 7, ratings: [17], ranks: [0], - names: ["Noob3"] + names: ["Noob3"], + uncertainties: [3] } ] @@ -40,40 +43,40 @@ defmodule Teiserver.Battle.SplitOneChevsInternalTest do assert result.team_groups == %{ 1 => [ - %{count: 1, group_rating: 6, members: ["Noob2"], ratings: [6]}, + %{count: 1, group_rating: 17, members: ["Noob3"], ratings: [17]}, %{count: 1, group_rating: 8, members: ["Pro1"], ratings: [8]} ], 2 => [ - %{count: 1, group_rating: 5, members: ["Noob1"], ratings: [5]}, - %{count: 1, group_rating: 17, members: ["Noob3"], ratings: [17]} + %{count: 1, group_rating: 6, members: ["Noob2"], ratings: [6]}, + %{count: 1, group_rating: 5, members: ["Noob1"], ratings: [5]} ] } end test "sort members" do members = [ - %{rating: 8, rank: 4, member_id: 100}, - %{rating: 5, rank: 0, member_id: 4}, - %{rating: 6, rank: 0, member_id: 2}, - %{rating: 17, rank: 0, member_id: 3} + %{rating: 8, rank: 4, member_id: 100, uncertainty: 8}, + %{rating: 5, rank: 1, member_id: 4, uncertainty: 1}, + %{rating: 6, rank: 0, member_id: 2, uncertainty: 2}, + %{rating: 17, rank: 1, member_id: 3, uncertainty: 3} ] result = SplitOneChevs.sort_members(members) assert result == [ - %{rating: 8, rank: 4, member_id: 100}, - %{rating: 17, rank: 0, member_id: 3}, - %{rating: 6, rank: 0, member_id: 2}, - %{rating: 5, rank: 0, member_id: 4} + %{member_id: 100, rank: 4, rating: 8, uncertainty: 8}, + %{member_id: 4, rank: 1, rating: 5, uncertainty: 1}, + %{member_id: 2, rank: 0, rating: 6, uncertainty: 2}, + %{member_id: 3, rank: 1, rating: 17, uncertainty: 3} ] end test "assign teams" do members = [ - %{rating: 8, rank: 4, member_id: 100, name: "100"}, - %{rating: 5, rank: 0, member_id: 4, name: "4"}, - %{rating: 6, rank: 0, member_id: 2, name: "2"}, - %{rating: 17, rank: 0, member_id: 3, name: "3"} + %{rating: 8, rank: 4, member_id: 100, name: "100", uncertainty: 0}, + %{rating: 5, rank: 0, member_id: 4, name: "4", uncertainty: 1}, + %{rating: 6, rank: 0, member_id: 2, name: "2", uncertainty: 2}, + %{rating: 17, rank: 0, member_id: 3, name: "3", uncertainty: 3} ] result = SplitOneChevs.assign_teams(members, 2) @@ -81,15 +84,15 @@ defmodule Teiserver.Battle.SplitOneChevsInternalTest do assert result.teams == [ %{ members: [ - %{rating: 17, rank: 0, member_id: 3, name: "3"}, - %{rating: 8, rank: 4, member_id: 100, name: "100"} + %{member_id: 3, name: "3", rank: 0, rating: 17, uncertainty: 3}, + %{member_id: 100, name: "100", rank: 4, rating: 8, uncertainty: 0} ], team_id: 1 }, %{ members: [ - %{rating: 6, rank: 0, member_id: 2, name: "2"}, - %{rating: 5, rank: 0, member_id: 4, name: "4"} + %{member_id: 2, name: "2", rank: 0, rating: 6, uncertainty: 2}, + %{member_id: 4, name: "4", rank: 0, rating: 5, uncertainty: 1} ], team_id: 2 } @@ -105,4 +108,76 @@ defmodule Teiserver.Battle.SplitOneChevsInternalTest do %{members: [], team_id: 3} ] end + + test "has enough noobs" do + expanded_group = [ + %{ + count: 2, + members: ["Pro1", "Noob1"], + group_rating: 13, + ratings: [8, 5], + ranks: [1, 0], + names: ["Pro1", "Noob1"], + uncertainties: [0, 1] + }, + %{ + count: 1, + members: ["Noob2"], + group_rating: 6, + ratings: [6], + ranks: [0], + names: ["Noob2"], + uncertainties: [2] + }, + %{ + count: 1, + members: ["Noob3"], + group_rating: 7, + ratings: [17], + ranks: [0], + names: ["Noob3"], + uncertainties: [3] + } + ] + + result = SplitOneChevs.has_enough_noobs?(expanded_group) + + assert result + end + + test "not have enough noobs" do + expanded_group = [ + %{ + count: 2, + members: ["B", "A"], + group_rating: 13, + ratings: [8, 5], + ranks: [2, 2], + names: ["B", "A"], + uncertainties: [0, 1] + }, + %{ + count: 1, + members: ["C"], + group_rating: 6, + ratings: [6], + ranks: [2], + names: ["C"], + uncertainties: [2] + }, + %{ + count: 1, + members: ["D"], + group_rating: 7, + ratings: [17], + ranks: [2], + names: ["D"], + uncertainties: [3] + } + ] + + result = SplitOneChevs.has_enough_noobs?(expanded_group) + + refute result + end end diff --git a/test/teiserver/battle/split_one_chevs_test.exs b/test/teiserver/battle/split_one_chevs_test.exs index 408af32cc..cf485ebe5 100644 --- a/test/teiserver/battle/split_one_chevs_test.exs +++ b/test/teiserver/battle/split_one_chevs_test.exs @@ -1,7 +1,7 @@ defmodule Teiserver.Battle.SplitOneChevsTest do @moduledoc """ - Can run tests in this file only by - mix test test/teiserver/battle/split_one_chevs_test.exs + Can run all balance tests via + mix test --only balance_test """ use ExUnit.Case @moduletag :balance_test @@ -28,7 +28,8 @@ defmodule Teiserver.Battle.SplitOneChevsTest do team_players: %{}, team_sizes: %{}, means: %{}, - stdevs: %{} + stdevs: %{}, + has_parties?: false } end @@ -36,10 +37,10 @@ defmodule Teiserver.Battle.SplitOneChevsTest do result = BalanceLib.create_balance( [ - %{1 => %{rating: 5}}, - %{2 => %{rating: 6}}, - %{3 => %{rating: 7}}, - %{4 => %{rating: 8}} + %{1 => %{rating: 5, rank: 2}}, + %{2 => %{rating: 6, rank: 2}}, + %{3 => %{rating: 7, rank: 2}}, + %{4 => %{rating: 8, rank: 2}} ], 4, algorithm: @split_algo @@ -52,27 +53,27 @@ defmodule Teiserver.Battle.SplitOneChevsTest do result = BalanceLib.create_balance( [ - %{1 => %{rating: 5}}, - %{2 => %{rating: 6}}, - %{3 => %{rating: 7}}, - %{4 => %{rating: 8}}, - %{5 => %{rating: 9}}, - %{6 => %{rating: 9}} + %{1 => %{rating: 5, rank: 2}}, + %{2 => %{rating: 6, rank: 2}}, + %{3 => %{rating: 7, rank: 2}}, + %{4 => %{rating: 8, rank: 2}}, + %{5 => %{rating: 9, rank: 2}}, + %{6 => %{rating: 9, rank: 2}} ], 3, algorithm: @split_algo ) - assert result.team_players == %{1 => [1, 5], 2 => [2, 6], 3 => [3, 4]} + assert result.team_players == %{1 => [5, 2], 2 => [6, 1], 3 => [4, 3]} end test "split one chevs simple group" do result = BalanceLib.create_balance( [ - %{4 => %{rating: 5}, 1 => %{rating: 8}}, - %{2 => %{rating: 6}}, - %{3 => %{rating: 7}} + %{4 => %{rating: 5, rank: 2}, 1 => %{rating: 8, rank: 2}}, + %{2 => %{rating: 6, rank: 2}}, + %{3 => %{rating: 7, rank: 2}} ], 2, rating_lower_boundary: 100, @@ -82,28 +83,31 @@ defmodule Teiserver.Battle.SplitOneChevsTest do algorithm: @split_algo ) - assert result.team_players == %{1 => [4, 1], 2 => [2, 3]} + assert result.team_players == %{1 => [1, 4], 2 => [2, 3]} end test "logs FFA" do result = BalanceLib.create_balance( [ - %{"Pro1" => %{rating: 5, rank: 1}}, - %{"Pro2" => %{rating: 6, rank: 1}}, - %{"Noob1" => %{rating: 7, rank: 0}}, - %{"Noob2" => %{rating: 8, rank: 0}} + %{"Pro1" => %{rating: 5, rank: 2}}, + %{"Pro2" => %{rating: 6, rank: 2}}, + %{"Noob1" => %{rating: 7, rank: 1, uncertainty: 7}}, + %{"Noob2" => %{rating: 8, rank: 0, uncertainty: 8}} ], 4, algorithm: @split_algo ) assert result.logs == [ - "Begin split_one_chevs balance", - "Pro2 (Chev: 2) picked for Team 1", - "Pro1 (Chev: 2) picked for Team 2", - "Noob2 (Chev: 1) picked for Team 3", - "Noob1 (Chev: 1) picked for Team 4" + "Algorithm: split_one_chevs", + "---------------------------", + "Your team will try and pick 3Chev+ players first, with preference for higher OS. If 1-2Chevs are the only remaining players, then lower uncertainty is preferred.", + "---------------------------", + "Pro2 (6, σ: 0, Chev: 3) picked for Team 1", + "Pro1 (5, σ: 0, Chev: 3) picked for Team 2", + "Noob1 (7, σ: 7, Chev: 2) picked for Team 3", + "Noob2 (8, σ: 8, Chev: 1) picked for Team 4" ] end @@ -111,21 +115,47 @@ defmodule Teiserver.Battle.SplitOneChevsTest do result = BalanceLib.create_balance( [ - %{"Pro1" => %{rating: 5, rank: 1}}, - %{"Pro2" => %{rating: 6, rank: 1}}, - %{"Noob1" => %{rating: 7, rank: 0}}, - %{"Noob2" => %{rating: 8, rank: 0}} + %{"Pro1" => %{rating: 5, rank: 2}}, + %{"Pro2" => %{rating: 6, rank: 2}}, + %{"Noob1" => %{rating: 7, rank: 0, uncertainty: 7.9}}, + %{"Noob2" => %{rating: 8, rank: 0, uncertainty: 8}} ], 2, algorithm: @split_algo ) assert result.logs == [ - "Begin split_one_chevs balance", - "Pro2 (Chev: 2) picked for Team 1", - "Pro1 (Chev: 2) picked for Team 2", - "Noob2 (Chev: 1) picked for Team 2", - "Noob1 (Chev: 1) picked for Team 1" + "Algorithm: split_one_chevs", + "---------------------------", + "Your team will try and pick 3Chev+ players first, with preference for higher OS. If 1-2Chevs are the only remaining players, then lower uncertainty is preferred.", + "---------------------------", + "Pro2 (6, σ: 0, Chev: 3) picked for Team 1", + "Pro1 (5, σ: 0, Chev: 3) picked for Team 2", + "Noob1 (7, σ: 7.9, Chev: 1) picked for Team 2", + "Noob2 (8, σ: 8, Chev: 1) picked for Team 1" + ] + end + + test "calls another balancer when no noobs" do + result = + BalanceLib.create_balance( + [ + %{"A" => %{rating: 5, rank: 2}}, + %{"B" => %{rating: 6, rank: 2}}, + %{"C" => %{rating: 7, rank: 2, uncertainty: 7.9}}, + %{"D" => %{rating: 8, rank: 2, uncertainty: 8}} + ], + 2, + algorithm: @split_algo + ) + + assert result.logs == [ + "Not enough new players; will use another balance algorithm.", + "---------------------------", + "Picked D for team 1, adding 8.0 points for new total of 8.0", + "Picked C for team 2, adding 7.0 points for new total of 7.0", + "Picked B for team 2, adding 6.0 points for new total of 13.0", + "Picked A for team 1, adding 5.0 points for new total of 13.0" ] end end diff --git a/test/teiserver/game/balancer_server_test.exs b/test/teiserver/game/balancer_server_test.exs new file mode 100644 index 000000000..f338b896e --- /dev/null +++ b/test/teiserver/game/balancer_server_test.exs @@ -0,0 +1,19 @@ +defmodule Teiserver.Game.BalancerServerTest do + @moduledoc false + use ExUnit.Case + @moduletag :balance_test + alias Teiserver.Game.BalancerServer + + test "get fuzz multiplier" do + result = BalancerServer.get_fuzz_multiplier([]) + assert result == 0 + + result = BalancerServer.get_fuzz_multiplier(algorithm: "loser_picks", fuzz_multiplier: 0.5) + assert result == 0.5 + + result = + BalancerServer.get_fuzz_multiplier(algorithm: "split_one_chevs", fuzz_multiplier: 0.5) + + assert result == 0 + end +end