Skip to content

Commit

Permalink
Add split-one-chevs-v2
Browse files Browse the repository at this point in the history
Add split-one-chevs-v2
Update moduledoc
  • Loading branch information
jauggy committed Jun 13, 2024
1 parent 95bfe6b commit 0dd77e3
Show file tree
Hide file tree
Showing 26 changed files with 1,003 additions and 140 deletions.
7 changes: 6 additions & 1 deletion config/dev.exs
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,12 @@ config :teiserver, Teiserver,
heartbeat_timeout: nil,
enable_discord_bridge: false,
enable_hailstorm: true,
accept_all_emails: true
accept_all_emails: true,

# The balance algorithm to use on the admin/matches/match/:id page
# It is purely used for analysis and not for actual games
# TODO move this into dropdown on admin/matches/match/:id page
analysis_balance_algorithm: "loser_picks"

# Watch static and templates for browser reloading.
config :teiserver, TeiserverWeb.Endpoint,
Expand Down
3 changes: 2 additions & 1 deletion lib/teiserver/battle/balance/balance_types.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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() :: %{
Expand Down
68 changes: 53 additions & 15 deletions lib/teiserver/battle/balance/split_one_chevs.ex
Original file line number Diff line number Diff line change
@@ -1,19 +1,26 @@
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

@splitter "---------------------------"

@doc """
Main entry point used by balance_lib
See split_one_chevs_internal_test.exs for sample input
Expand All @@ -30,43 +37,74 @@ 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

defp round_number(rating_value) when is_float(rating_value) do
rating_value
|> Decimal.from_float()
|> Decimal.round(1)
end

defp round_number(rating_value) when is_integer(rating_value) do
rating_value
end

@doc """
Assigns teams using algorithm defined in moduledoc
See split_one_chevs_internal_test.exs for sample input
"""
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} (#{round_number(x.rating)}, σ: #{round_number(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)],
Expand Down
114 changes: 90 additions & 24 deletions lib/teiserver/battle/libs/balance_lib.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
%{
Expand All @@ -40,6 +42,11 @@ defmodule Teiserver.Battle.BalanceLib do
}
end

defp 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
%{
Expand Down Expand Up @@ -81,6 +88,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
%{
Expand All @@ -93,7 +106,8 @@ defmodule Teiserver.Battle.BalanceLib do
team_players: %{},
team_sizes: %{},
means: %{},
stdevs: %{}
stdevs: %{},
has_parties?: false
}
end

Expand All @@ -118,6 +132,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} ->
Expand All @@ -133,15 +156,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)
Expand All @@ -164,9 +190,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)
Expand All @@ -176,7 +209,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

Expand Down Expand Up @@ -221,7 +254,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

Expand Down Expand Up @@ -560,17 +594,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
Expand All @@ -586,26 +613,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
Expand Down Expand Up @@ -837,4 +877,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
16 changes: 10 additions & 6 deletions lib/teiserver/data/cache_user.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -1310,13 +1312,15 @@ 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, ~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
Expand Down
Loading

0 comments on commit 0dd77e3

Please sign in to comment.