Created
November 24, 2023 19:41
-
-
Save fbatista/5d811ea1f28ea30a16ecdeb1c67d2c51 to your computer and use it in GitHub Desktop.
Tournament Sim to check bye impact
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
POINTS_PER_WIN = 5 | |
POINTS_PER_BYE = 4 | |
POINTS_PER_DRAW = 1 | |
DRAW_CHANCE = 0.15 | |
NUMBER_OF_PLAYERS = 51 | |
TOP_CUT = 16 | |
ITERATIONS = 10 | |
Player = Struct.new(:id, :name, :opponents, :points, :tiebreakers, :matched, :skill, keyword_init: true) do |new_class| | |
def played_against?(player) | |
opponents.include?(player) | |
end | |
def omwpercent | |
return 0 if tiebreakers.size == 0 | |
opponents = [] | |
tiebreakers.each do |round, tiebreaker| | |
opponents += tiebreaker.players | |
end | |
return 0 if opponents.size == 0 | |
opponents.inject(0.0) { |sum, o| sum + o.mwpercent } / opponents.count.to_f | |
end | |
def mwpercent_mtr | |
return 0.0 if tiebreakers.size == 0 | |
return 0.0 if points == 0 | |
points / (tiebreakers.values.count * POINTS_PER_WIN).to_f | |
end | |
def mwpercent_mstr | |
return 0.0 if tiebreakers.values.reject(&:bye?).size == 0 | |
return 0.0 if (points - (tiebreakers.values.count(&:bye?) * POINTS_PER_WIN)) == 0 | |
(points - (tiebreakers.values.count(&:bye?) * POINTS_PER_WIN)) / ((tiebreakers.values.count - tiebreakers.values.count(&:bye?)) * POINTS_PER_WIN).to_f | |
end | |
def mwpercent | |
mwpercent_mstr | |
end | |
def oampoints | |
return 0 if tiebreakers.size == 0 | |
opponents = [] | |
tiebreakers.each do |round, tiebreaker| | |
opponents += tiebreaker.players | |
end | |
return 0 if opponents.size == 0 | |
opponents.inject(0.0) { |sum, o| sum + o.points } / opponents.count.to_f | |
end | |
def rank_mtr | |
# 15 0.8945 0.7345 | |
# 150_894_507_345 | |
points * 10_000_000_000 + (omwpercent * 1_000_000_000).to_i + (mwpercent * 10_000).to_i | |
end | |
def rank_mstr | |
# 15 0.8945 11.34 0.8945 | |
# 1_508_945_113_408_945 | |
points * 100_000_000_000_000 + (mwpercent * 10_000_000_000_000).to_i + (oampoints * 10_000_000).to_i + (omwpercent * 10_000).to_i | |
end | |
end | |
Tiebreaker = Struct.new(:type, :players, keyword_init: true) do |new_class| | |
def bye? | |
type == :bye | |
end | |
end | |
Pod = Struct.new(:id, :name, :rounds, keyword_init: true) do |new_class| | |
def can_match?(player, round) | |
return false if filled?(round) | |
rounds[round - 1].select { |p| p.played_against?(player) }.empty? | |
end | |
def suitable_rank?(player, round) | |
return false unless filled?(round) | |
player.rank_mstr * 0.9 <= pod_rank_avg(round) && player.rank_mstr * 1.1 >= pod_rank_avg(round) | |
end | |
def pod_rank_avg(round) | |
rounds[round - 1].sum(&:rank_mstr) / rounds[round - 1].size.to_f | |
end | |
def suitable_matches?(player, round) | |
return false unless filled?(round) | |
rounds[round - 1].count { |p| p.played_against?(player) } <= 1 | |
end | |
def a_swap_candidate_by_matches(player, round) | |
rounds[round - 1].find { |p| p.played_against?(player) } || rounds[round - 1].last | |
end | |
def best_swap_candidate_by_rank(player, round) | |
rounds[round - 1].min { |a, b| (a.rank_mstr - player.rank_mstr).abs <=> (b.rank_mstr - player.rank_mstr).abs } | |
end | |
def swap!(player, candidate_player, round) | |
rounds[round - 1].delete(candidate_player) | |
rounds[round - 1].push(player) | |
player.matched = true | |
candidate_player.matched = false | |
end | |
def match!(player, round) | |
rounds[round - 1].push(player) | |
player.matched = true | |
end | |
def filled?(round) | |
rounds[round - 1].size == 4 | |
end | |
end | |
def fix_unmached_players(unmatched_players, pods, round) | |
unmatched_players.each do |player| | |
match_not_found = pods.select { |pod| pod.filled?(round) }.sort { |a, b| (a.pod_rank_avg(round) - player.rank_mstr).abs <=> (b.pod_rank_avg(round) - player.rank_mstr).abs }.each do |pod| | |
if pod.suitable_rank?(player, round) | |
candidate_player = if pod.suitable_matches?(player, round) | |
pod.a_swap_candidate_by_matches(player, round) | |
else | |
pod.best_swap_candidate_by_rank(player, round) | |
end | |
unfilled_pods = pods.reject { |pod| pod.filled?(round) }.reverse | |
next unless unfilled_pods.any? { |p| p.can_match?(candidate_player, round) } | |
pod.swap!(player, candidate_player, round) | |
new_pod = unfilled_pods.find { |p| p.can_match?(candidate_player, round) } | |
new_pod.match!(candidate_player, round) | |
break(false) | |
end | |
end | |
next unless match_not_found | |
pods.reject { |pod| pod.filled?(round) }.last&.match!(player, round) | |
end | |
end | |
def award_byes(unmatched_players, round) | |
unmatched_players.each do |player| | |
player.points += POINTS_PER_BYE | |
player.tiebreakers[round] = Tiebreaker.new(type: :bye, players: []) | |
end | |
end | |
def score_round(pods, round) | |
pods.each do |pod| | |
# store opponents to avoid repetition | |
pod.rounds[round - 1].each do |player| | |
player.opponents += pod.rounds[round - 1].reject { |p| p == player } | |
end | |
# configure chance to draw here | |
if rand <= DRAW_CHANCE | |
# draw | |
pod.rounds[round - 1].each do |p| | |
# configure points per draw here | |
p.points += POINTS_PER_DRAW | |
p.tiebreakers[round] = Tiebreaker.new(type: :draw, players: pod.rounds[round - 1].reject { |opponent| opponent == p }) | |
end | |
else | |
# win | |
roll = rand * pod.rounds[round - 1].sum(&:skill) | |
accum = 0 | |
winner = pod.rounds[round - 1].each do |player| | |
accum += player.skill | |
break(player) if accum >= roll | |
end | |
# configure points per win here | |
winner.points += POINTS_PER_WIN | |
losers = pod.rounds[round -1].reject { |opponent| opponent == winner } | |
winner.tiebreakers[round] = Tiebreaker.new(type: :win, players: losers) | |
losers.each do |p| | |
p.tiebreakers[round] = Tiebreaker.new(type: :loss, players: pod.rounds[round -1].reject { |opponent| opponent == p }) | |
end | |
end | |
end | |
end | |
def print_standings(players) | |
puts "Standings:" | |
puts "+#{"".ljust(6, "-")}+#{"".ljust(14, "-")}+#{"".ljust(5, "-")}+#{"".ljust(9, "-")}+#{"".ljust(8, "-")}+#{"".ljust(9, "-")}+" | |
puts "|#{" #".ljust(6)}|#{" Name".ljust(14)}|#{" MP".ljust(5)}|#{" MW%".ljust(9)}|#{" OAMP".ljust(8)}|#{" OMW%".ljust(9)}|" | |
puts "+#{"".ljust(6, "-")}+#{"".ljust(14, "-")}+#{"".ljust(5, "-")}+#{"".ljust(9, "-")}+#{"".ljust(8, "-")}+#{"".ljust(9, "-")}+" | |
players.each.with_index do |player, i| | |
puts "| #{(i+1).to_s.ljust(5)}| #{player.name.ljust(13)}| #{player.points.to_s.ljust(4)}| #{(player.mwpercent * 100).round(2).to_s.ljust(6)}% | #{player.oampoints.round(2).to_s.ljust(7)}| #{(player.omwpercent * 100).round(2).to_s.ljust(6)}% |" | |
end | |
puts "+#{"".ljust(6, "-")}+#{"".ljust(14, "-")}+#{"".ljust(5, "-")}+#{"".ljust(9, "-")}+#{"".ljust(8, "-")}+#{"".ljust(9, "-")}+" | |
end | |
def print_pairings(pods, round, players_with_byes) | |
puts "Pairings for round #{round}:" | |
puts "+#{"".ljust(9, "-")}+#{"".ljust(55, "-")}+" | |
pods.each do |pod| | |
puts "| #{pod.name.ljust(8)}| #{pod.rounds[round - 1].map { |player| player.name.ljust(12) }.join('; ')}|" | |
puts "+#{"".ljust(9, "-")}+#{"".ljust(55, "-")}+" | |
end | |
players_with_byes.each do |player| | |
puts "|#{" BYE".ljust(8)} |#{player.name.ljust(55)}|" | |
end | |
puts "+#{"".ljust(9, "-")}+#{"".ljust(55, "-")}+" | |
end | |
# Prepare simulator | |
def simulate | |
players = (1..NUMBER_OF_PLAYERS).to_a.map do |i| | |
Player.new( | |
id: i, | |
name: "Player #{i}", | |
opponents: [], | |
tiebreakers: {}, | |
points: 0, | |
matched: false, | |
skill: ( | |
case i | |
when 1..((NUMBER_OF_PLAYERS * 0.05).to_i) | |
0.5 | |
when ((NUMBER_OF_PLAYERS * 0.05).to_i)..((NUMBER_OF_PLAYERS * 0.15).to_i) | |
0.4 | |
when ((NUMBER_OF_PLAYERS * 0.15).to_i)..((NUMBER_OF_PLAYERS * 0.50).to_i) | |
0.3 | |
when ((NUMBER_OF_PLAYERS * 0.50).to_i)..((NUMBER_OF_PLAYERS * 0.95).to_i) | |
0.25 | |
else | |
0.1 | |
end | |
) | |
) | |
end | |
binding.irb | |
num_rounds = Math.log(players.length, 4).ceil + 2 | |
pods = (1..((players.length / 4.0).floor)).to_a.map do |i| | |
Pod.new( | |
id: i, | |
name: "Pod #{i}", | |
rounds: Array.new(num_rounds).map { |_| [] } | |
) | |
end | |
# Run simulator | |
(1..num_rounds).each do |round| | |
# puts "\nRound #{round}" | |
if round == 1 | |
players.shuffle! | |
else | |
# sort by standings, top to bottom | |
players.sort! { |a, b| b.rank_mstr <=> a.rank_mstr } | |
players.each do |player| | |
player.matched = false | |
end | |
# puts "\n" | |
# print_standings(players) | |
end | |
# pairings | |
# puts "\n" | |
pods.each do |pod| | |
players.each do |player| | |
next if player.matched | |
pod.match!(player, round) if pod.can_match?(player, round) | |
break if pod.filled?(round) | |
end | |
end | |
fix_unmached_players(players.reject(&:matched), pods, round) | |
players_with_byes = award_byes(players.reject(&:matched), round) | |
# output pairings | |
# print_pairings(pods, round, players_with_byes) | |
# scoring | |
score_round(pods, round) | |
end | |
# final standings | |
players.sort! { |a, b| b.rank_mstr <=> a.rank_mstr } | |
# puts "\nFinal " | |
# print_standings(players) | |
end | |
average_skill = 0 | |
average_byes = 0 | |
ITERATIONS.times do | |
top = simulate[0...TOP_CUT] | |
average_skill += top.sum(&:skill) / top.length | |
average_byes += top.sum { |p| p.tiebreakers.values.any?(&:bye?)? 1 : 0 } | |
end | |
puts "Average win% of players in top: #{(average_skill / ITERATIONS.to_f) * 100.0 }%" | |
puts "Average number of players with byes in top: #{average_byes / ITERATIONS.to_f}" | |
# # check tournament quality | |
# no_repetition = players.all? do |player| | |
# (player.opponents.uniq.size == player.opponents.size) && (player.opponents.size == num_rounds * 3) | |
# end | |
# if no_repetition | |
# puts "\nTournament without rematches or byes!" | |
# else | |
# puts "\nTournament with rematches / byes!" | |
# invalid_players = players.reject do |player| | |
# (player.opponents.uniq.size == player.opponents.size) && (player.opponents.size == num_rounds * 3) | |
# end | |
# invalid_players.each do |player| | |
# puts "#{player.name} opponents: #{player.opponents.map(&:id).join(', ')}" | |
# end | |
# end |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment