Skip to content

Instantly share code, notes, and snippets.

@fbatista
Last active September 21, 2023 09:31
Show Gist options
  • Save fbatista/12c0c94491b4f479b2d71411b080efa2 to your computer and use it in GitHub Desktop.
Save fbatista/12c0c94491b4f479b2d71411b080efa2 to your computer and use it in GitHub Desktop.
Tournament Matchmaking simulator, top-bottom approach
POINTS_PER_WIN = 5
POINTS_PER_DRAW = 1
DRAW_CHANCE = 0.28
NUMBER_OF_PLAYERS = 51
Player = Struct.new(:id, :name, :opponents, :points, :tiebreakers, :matched, 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_WIN
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
winner = pod.rounds[round - 1].sample
# 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
players = (1..NUMBER_OF_PLAYERS).to_a.map do |i|
Player.new(
id: i,
name: "Player #{i}",
opponents: [],
tiebreakers: {},
points: 0,
matched: false
)
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
num_rounds = Math.log(players.length, 4).ceil
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)
# 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