Skip to content

Instantly share code, notes, and snippets.

@fbatista
Created November 24, 2023 19:41
Show Gist options
  • Save fbatista/5d811ea1f28ea30a16ecdeb1c67d2c51 to your computer and use it in GitHub Desktop.
Save fbatista/5d811ea1f28ea30a16ecdeb1c67d2c51 to your computer and use it in GitHub Desktop.
Tournament Sim to check bye impact
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