Skip to content

Commit

Permalink
Add brute force balance algo
Browse files Browse the repository at this point in the history
  • Loading branch information
jauggy committed Jul 14, 2024
1 parent 4e28cd9 commit 0200b85
Show file tree
Hide file tree
Showing 9 changed files with 706 additions and 2 deletions.
242 changes: 242 additions & 0 deletions lib/teiserver/battle/balance/brute_force.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,242 @@
defmodule Teiserver.Battle.Balance.BruteForce do
@moduledoc """
Algorithm idea by Suuwassea
Adjusted by jauggy
Overview:
Go through every possible combination and pick the best one. Ideal for keeping parties together.
The best combination will have the lowest score.
Score = difference in team rating + broken party penalty
broken party penalty = num broken parties * broken party multiplier
Only use for games with two teams and <=16 players
"""
alias Teiserver.Battle.Balance.BalanceTypes, as: BT
alias Teiserver.Battle.Balance.BruteForceTypes, as: BF
import Teiserver.Helper.NumberHelper, only: [format: 1]
require Integer

@broken_party_multiplier 3
@splitter "------------------------------------------------------"

@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
input_data = %{
players: flatten_members(expanded_group),
parties: get_parties(expanded_group)
}

case should_use_algo?(input_data, team_count) do
:ok ->
potential_teams = potential_teams(length(input_data.players))
best_combo = get_best_combo(potential_teams, input_data.players, input_data.parties)
standardise_result(best_combo, input_data.players)

{:error, message} ->
# Call another balancer
result = Teiserver.Battle.Balance.LoserPicks.perform(expanded_group, team_count, opts)

new_logs =
["#{message} Will use another balance algorithm instead.", @splitter, result.logs]
|> List.flatten()

Map.put(result, :logs, new_logs)
end
end

@doc """
Use this algo if two teams and <=16 players
"""
@spec should_use_algo?(BR.input_data(), integer()) :: :ok | {:error, String.t()}

Check warning on line 53 in lib/teiserver/battle/balance/brute_force.ex

View workflow job for this annotation

GitHub Actions / Dialyzer

unknown_type

Unknown type: BR.input_data/0.
def should_use_algo?(input_data, team_count) do
num_players = length(input_data[:players])

cond do
team_count != 2 -> {:error, "Team count doesn't equal two."}
num_players > 16 -> {:error, "Number of players greater than 16."}
Integer.is_odd(num_players) -> {:error, "Odd number of players."}
true -> :ok
end
end

@doc """
Remove all groups/parties and treats everyone as solo players. This algorithm doesn't support parties.
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,
uncertainties: uncertainties
} <- 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: rating,
name: name,
id: id
}
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 ->
y[:names]
end)
end

@spec potential_teams(integer()) :: any()
def potential_teams(num_players) do
Teiserver.Helper.CombinationsHelper.get_combinations(num_players)
end

@spec get_best_combo([integer()], [BF.player()], [String.t()]) :: BF.combo_result()
def get_best_combo(combos, players, parties) do
Enum.map(combos, fn x ->
get_players_from_indexes(x, players)
end)
|> Enum.map(fn team ->
result = score_combo(team, players, parties)
Map.put(result, :team, team)
end)
|> Enum.min_by(fn z ->
z.score
end)
end

@spec score_combo([BF.player()], [BF.player()], [String.t()]) :: any()
def score_combo(first_team, all_players, 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)
broken_party_penalty = count_broken_parties(first_team, parties) * @broken_party_multiplier

score = rating_diff_penalty + broken_party_penalty

%{
score: score,
rating_diff_penalty: rating_diff_penalty,
broken_party_penalty: broken_party_penalty
}
end

def get_players_from_indexes(player_indexes, players) do
players = Enum.with_index(players)

Enum.filter(players, fn {_player, index} ->
Enum.member?(player_indexes, index)
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.name == 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 get_team_rating(players) do
Enum.reduce(players, 0, fn x, acc ->
acc + x.rating
end)
end

@spec standardise_result(BF.combo_result(), [BF.player()]) :: any()
def standardise_result(best_combo, all_players) do
first_team = best_combo.team

second_team =
all_players
|> Enum.filter(fn x ->
!Enum.any?(first_team, fn y ->
y.id == x.id
end)
end)

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)
}

logs = [
"Algorithm: brute_force",
@splitter,
"Team rating diff penalty: #{format(best_combo.rating_diff_penalty)}",
"Broken party penalty: #{best_combo.broken_party_penalty}",
"Score: #{format(best_combo.score)} (lower is better)",
"Team 1: #{log_team(first_team)}",
"Team 2: #{log_team(second_team)}"
]

%{
team_groups: team_groups,
team_players: team_players,
logs: logs
}
end

@spec log_team([BF.player()]) :: String.t()
defp log_team(team) do
Enum.map(team, fn x ->
x.name
end)
|> Enum.join(", ")
end

@spec standardise_team_groups([BF.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
end
24 changes: 24 additions & 0 deletions lib/teiserver/battle/balance/brute_force_types.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
defmodule Teiserver.Battle.Balance.BruteForceTypes 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_party_penalty: number(),
rating_diff_penalty: number(),
score: number(),
team: [player()]
}
end
3 changes: 2 additions & 1 deletion lib/teiserver/battle/libs/balance_lib.ex
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,8 @@ defmodule Teiserver.Battle.BalanceLib do
"loser_picks" => Teiserver.Battle.Balance.LoserPicks,
"force_party" => Teiserver.Battle.Balance.ForceParty,
"cheeky_switcher_smart" => Teiserver.Battle.Balance.CheekySwitcherSmart,
"split_one_chevs" => Teiserver.Battle.Balance.SplitOneChevs
"split_one_chevs" => Teiserver.Battle.Balance.SplitOneChevs,
"brute_force" => Teiserver.Battle.Balance.BruteForce
}
end

Expand Down
30 changes: 30 additions & 0 deletions lib/teiserver/helpers/combinations_helper.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
defmodule Teiserver.Helper.CombinationsHelper do
# This module to help get combinations taken from Elixir Forums:
# https://elixirforum.com/t/create-all-possible-teams-from-a-list-of-players/64892/5?u=joshua.aug
require Integer

defp n_comb(0, _list), do: [[]]
defp n_comb(_, []), do: []

defp n_comb(n, [h | t]) do
list = for l <- n_comb(n - 1, t), do: [h | l]
list ++ n_comb(n, t)
end

def n_comb(list) when is_list(list) do
n = trunc(length(list) / 2)

for i <- n_comb(n - 1, tl(list)),
do: [hd(list) | i]
end

# Returns a list of possible combinations of a single team using indexes starting at zero
# but doesn't include duplicates. Assumes we need exactly two teams of equal size.
# E.g. for a 4 player lobby, Team1: [0,1] is a duplicate of Team1: [2,3]
# since Team1 and Team2 are just swapped
def get_combinations(num_players) when is_integer(num_players) do
last = trunc(num_players) - 1

0..last |> Enum.to_list() |> n_comb()
end
end
8 changes: 8 additions & 0 deletions lib/teiserver_web/live/battles/match/show.html.heex
Original file line number Diff line number Diff line change
Expand Up @@ -312,6 +312,10 @@
<td>Deviation</td>
<td><%= @past_balance.deviation %></td>
</tr>
<tr>
<td>Time Taken (ms)</td>
<td><%= @past_balance.time_taken/1000 %></td>
</tr>
</tbody>
</table>

Expand All @@ -334,6 +338,10 @@
<td>Deviation</td>
<td><%= @new_balance.deviation %></td>
</tr>
<tr>
<td>Time Taken (ms)</td>
<td><%= @new_balance.time_taken/1000 %></td>
</tr>
</tbody>
</table>

Expand Down
3 changes: 2 additions & 1 deletion mix.exs
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,8 @@ defmodule Teiserver.MixProject do
{:etop, "~> 0.7.0"},
{:cowlib, "~> 2.11", hex: :remedy_cowlib, override: true},
{:json_xema, "~> 0.3"},
{:nostrum, "~> 0.8"}
{:nostrum, "~> 0.8"},
{:combination, "~> 0.0.3"}
]
end

Expand Down
1 change: 1 addition & 0 deletions mix.lock
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
"castore": {:hex, :castore, "1.0.3", "7130ba6d24c8424014194676d608cb989f62ef8039efd50ff4b3f33286d06db8", [:mix], [], "hexpm", "680ab01ef5d15b161ed6a95449fac5c6b8f60055677a8e79acf01b27baa4390b"},
"certifi": {:hex, :certifi, "2.9.0", "6f2a475689dd47f19fb74334859d460a2dc4e3252a3324bd2111b8f0429e7e21", [:rebar3], [], "hexpm", "266da46bdb06d6c6d35fde799bcb28d36d985d424ad7c08b5bb48f5b5cdd4641"},
"chacha20": {:hex, :chacha20, "1.0.4", "0359d8f9a32269271044c1b471d5cf69660c362a7c61a98f73a05ef0b5d9eb9e", [:mix], [], "hexpm", "2027f5d321ae9903f1f0da7f51b0635ad6b8819bc7fe397837930a2011bc2349"},
"combination": {:hex, :combination, "0.0.3", "746aedca63d833293ec6e835aa1f34974868829b1486b1e1cb0685f0b2ae1f41", [:mix], [], "hexpm", "72b099f463df42ef7dc6371d250c7070b57b6c5902853f69deb894f79eda18ca"},
"combine": {:hex, :combine, "0.10.0", "eff8224eeb56498a2af13011d142c5e7997a80c8f5b97c499f84c841032e429f", [:mix], [], "hexpm", "1b1dbc1790073076580d0d1d64e42eae2366583e7aecd455d1215b0d16f2451b"},
"comeonin": {:hex, :comeonin, "5.3.3", "2c564dac95a35650e9b6acfe6d2952083d8a08e4a89b93a481acb552b325892e", [:mix], [], "hexpm", "3e38c9c2cb080828116597ca8807bb482618a315bfafd98c90bc22a821cc84df"},
"con_cache": {:hex, :con_cache, "1.0.0", "6405e2bd5d5005334af72939432783562a8c35a196c2e63108fe10bb97b366e6", [:mix], [], "hexpm", "4d1f5cb1a67f3c1a468243dc98d10ac83af7f3e33b7e7c15999dc2c9bc0a551e"},
Expand Down
Loading

0 comments on commit 0200b85

Please sign in to comment.