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 @@