Last active
September 21, 2023 09:31
-
-
Save fbatista/12c0c94491b4f479b2d71411b080efa2 to your computer and use it in GitHub Desktop.
Tournament Matchmaking simulator, top-bottom approach
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_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