tournament-system 0.1.4 → 0.1.5
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/lib/tournament/algorithm/page_playoff.rb +34 -0
- data/lib/tournament/algorithm/pairers/adjacent.rb +21 -0
- data/lib/tournament/algorithm/pairers/best_min_duplicates.rb +55 -0
- data/lib/tournament/algorithm/pairers/halves.rb +32 -0
- data/lib/tournament/algorithm/pairers/multi.rb +33 -0
- data/lib/tournament/algorithm/pairers.rb +7 -0
- data/lib/tournament/algorithm/round_robin.rb +60 -0
- data/lib/tournament/algorithm/single_bracket.rb +104 -0
- data/lib/tournament/algorithm/swiss.rb +83 -0
- data/lib/tournament/algorithm/util.rb +100 -0
- data/lib/tournament/algorithm.rb +6 -0
- data/lib/tournament/driver.rb +100 -9
- data/lib/tournament/page_playoff.rb +21 -18
- data/lib/tournament/round_robin.rb +27 -33
- data/lib/tournament/single_elimination.rb +23 -44
- data/lib/tournament/swiss/dutch.rb +39 -37
- data/lib/tournament/swiss.rb +16 -21
- data/lib/tournament/version.rb +2 -1
- data/lib/tournament-system.rb +0 -2
- data/lib/tournament.rb +6 -0
- metadata +14 -7
- data/lib/tournament/seeder/none.rb +0 -12
- data/lib/tournament/seeder/random.rb +0 -14
- data/lib/tournament/seeder/single_bracket.rb +0 -39
- data/lib/tournament/seeder.rb +0 -10
- data/lib/tournament/swiss/common.rb +0 -118
@@ -0,0 +1,100 @@
|
|
1
|
+
module Tournament
|
2
|
+
module Algorithm
|
3
|
+
# This module provides utility functions for helping implement other
|
4
|
+
# algorithms.
|
5
|
+
module Util
|
6
|
+
extend self
|
7
|
+
|
8
|
+
# Padd an array of teams to be even.
|
9
|
+
#
|
10
|
+
# @param teams [Array<team>]
|
11
|
+
# @return [Array<team, nil>]
|
12
|
+
def padd_teams(teams)
|
13
|
+
if teams.length.odd?
|
14
|
+
teams + [nil]
|
15
|
+
else
|
16
|
+
teams
|
17
|
+
end
|
18
|
+
end
|
19
|
+
|
20
|
+
# Padd the count of teams to be even.
|
21
|
+
#
|
22
|
+
# @example
|
23
|
+
# padded_teams_count(teams.length/) == padd_teams(teams).length
|
24
|
+
#
|
25
|
+
# @param teams_count [Integer] the number of teams
|
26
|
+
# @return [Integer]
|
27
|
+
def padded_teams_count(teams_count)
|
28
|
+
(teams_count / 2.0).ceil * 2
|
29
|
+
end
|
30
|
+
|
31
|
+
# rubocop:disable Metrics/MethodLength
|
32
|
+
|
33
|
+
# Collect all values in an array with a minimum value.
|
34
|
+
#
|
35
|
+
# @param array [Array<element>]
|
36
|
+
# @yieldparam element an element of the array
|
37
|
+
# @yieldreturn [#<, #==] some value to find the minimum of
|
38
|
+
# @return [Array<element>] all elements with the minimum value
|
39
|
+
def all_min_by(array)
|
40
|
+
min_elements = []
|
41
|
+
min_value = nil
|
42
|
+
|
43
|
+
array.each do |element|
|
44
|
+
value = yield element
|
45
|
+
|
46
|
+
if !min_value || value < min_value
|
47
|
+
min_elements = [element]
|
48
|
+
min_value = value
|
49
|
+
elsif value == min_value
|
50
|
+
min_elements << element
|
51
|
+
end
|
52
|
+
end
|
53
|
+
|
54
|
+
min_elements
|
55
|
+
end
|
56
|
+
# rubocop:enable Metrics/MethodLength
|
57
|
+
|
58
|
+
# rubocop:disable Metrics/MethodLength
|
59
|
+
# :reek:NestedIterators
|
60
|
+
|
61
|
+
# Iterate all perfect matches of a specific size.
|
62
|
+
# A perfect matching is a unique grouping of all elements with a specific
|
63
|
+
# group size. For example +[[1, 2], [3, 4]]+ is a perfect match of
|
64
|
+
# +[1, 2, 3, 4]+ with group size of +2+.
|
65
|
+
#
|
66
|
+
# This is useful for example when a pairing algorithm needs to iterate all
|
67
|
+
# possible pairings of teams to determine which is the best.
|
68
|
+
#
|
69
|
+
# Warning, this currently only works for +size = 2+.
|
70
|
+
#
|
71
|
+
# @param array [Array<element>]
|
72
|
+
# @param size [Integer] the size of groups
|
73
|
+
# @overload all_perfect_matches(array, size)
|
74
|
+
# @return [Enumerator<Array<element>>] enumerator for all perfect
|
75
|
+
# matches
|
76
|
+
# @overload all_perfect_matches(array, size) { |group| ... }
|
77
|
+
# @yieldparam group [Array(element, size)] a group of elements
|
78
|
+
# @return [void]
|
79
|
+
def all_perfect_matches(array, size)
|
80
|
+
size = 2
|
81
|
+
return to_enum(:all_perfect_matches, array, size) unless block_given?
|
82
|
+
|
83
|
+
if array.empty?
|
84
|
+
yield []
|
85
|
+
return
|
86
|
+
end
|
87
|
+
|
88
|
+
array[1..-1].combination(size - 1) do |group|
|
89
|
+
group.unshift array[0]
|
90
|
+
|
91
|
+
remaining = array.reject { |element| group.include?(element) }
|
92
|
+
all_perfect_matches(remaining, size) do |groups|
|
93
|
+
yield [group] + groups
|
94
|
+
end
|
95
|
+
end
|
96
|
+
end
|
97
|
+
# rubbocop:enable Metrics/MethodLength
|
98
|
+
end
|
99
|
+
end
|
100
|
+
end
|
data/lib/tournament/driver.rb
CHANGED
@@ -1,4 +1,6 @@
|
|
1
1
|
module Tournament
|
2
|
+
# :reek:UnusedParameters
|
3
|
+
|
2
4
|
# An interface for external tournament data.
|
3
5
|
#
|
4
6
|
# To use any tournament system implemented in this gem, simply subclass this
|
@@ -11,42 +13,72 @@ module Tournament
|
|
11
13
|
# Certain tournament systems will not make use of certain parts of this
|
12
14
|
# interface. You can for example leave out `#get_team_score` if you're not
|
13
15
|
# using the Swiss tournament system.
|
14
|
-
#
|
16
|
+
#
|
17
|
+
# This class caches certain calculations/objects, it is designed to be a
|
18
|
+
# one-time use with any one tournament system. Reusing an instance may result
|
19
|
+
# in undefined behaviour.
|
15
20
|
class Driver
|
16
21
|
# rubocop:disable Lint/UnusedMethodArgument
|
17
22
|
# :nocov:
|
18
23
|
|
19
|
-
# Get all matches
|
24
|
+
# Get all matches. Required to implement.
|
25
|
+
#
|
26
|
+
# @return [Array<match>]
|
20
27
|
def matches
|
21
28
|
raise 'Not Implemented'
|
22
29
|
end
|
23
30
|
|
24
|
-
# Get the teams
|
31
|
+
# Get the teams with their initial seedings. Required to implement.
|
32
|
+
#
|
33
|
+
# @return [Array<team>]
|
25
34
|
def seeded_teams
|
26
35
|
raise 'Not Implemented'
|
27
36
|
end
|
28
37
|
|
29
|
-
# Get the teams
|
38
|
+
# Get the teams ranked by their current position in the tournament.
|
39
|
+
# Required to implement.
|
40
|
+
#
|
41
|
+
# @return [Array<team>]
|
30
42
|
def ranked_teams
|
31
43
|
raise 'Not Implemented'
|
32
44
|
end
|
33
45
|
|
34
|
-
# Get the winning team of a match
|
46
|
+
# Get the winning team of a match. Required to implement.
|
47
|
+
#
|
48
|
+
# @param match [] a match, eg. one returned by {#matches}
|
49
|
+
# @return [team, nil] the winner of the match if applicable
|
35
50
|
def get_match_winner(match)
|
36
51
|
raise 'Not Implemented'
|
37
52
|
end
|
38
53
|
|
39
|
-
# Get
|
54
|
+
# Get the pair of teams playing for a match. Required to implement.
|
55
|
+
#
|
56
|
+
# @param match [] a match, eg. one returned by {#matches}
|
57
|
+
# @return [Array(team, team)] the pair of teams playing in the match
|
40
58
|
def get_match_teams(match)
|
41
59
|
raise 'Not Implemented'
|
42
60
|
end
|
43
61
|
|
44
|
-
# Get a specific score for a team
|
62
|
+
# Get a specific score for a team. Required to implement.
|
63
|
+
#
|
64
|
+
# @param team [] a team, eg. one returned by {#seeded_teams}
|
65
|
+
# @return [Number] the score of the team
|
45
66
|
def get_team_score(team)
|
46
67
|
raise 'Not Implemented'
|
47
68
|
end
|
48
69
|
|
49
|
-
#
|
70
|
+
# Called when a match is created by a tournament system. Required to
|
71
|
+
# implement.
|
72
|
+
#
|
73
|
+
# @example rails
|
74
|
+
# def build_match(home_team, away_team)
|
75
|
+
# Match.create!(home_team, away_team)
|
76
|
+
# end
|
77
|
+
#
|
78
|
+
# @param home_team [team] the home team for the match, never +nil+
|
79
|
+
# @param away_team [team, nil] the away team for the match, may be +nil+ for
|
80
|
+
# byes.
|
81
|
+
# @return [void]
|
50
82
|
def build_match(home_team, away_team)
|
51
83
|
raise 'Not Implemented'
|
52
84
|
end
|
@@ -54,17 +86,76 @@ module Tournament
|
|
54
86
|
# :nocov:
|
55
87
|
# rubocop:enable Lint/UnusedMethodArgument
|
56
88
|
|
57
|
-
# Get the losing team of a specific match
|
89
|
+
# Get the losing team of a specific match. By default uses
|
90
|
+
# {#get_match_winner} and {#get_match_teams} to determine which team lost.
|
91
|
+
# Override if you have better access to this information.
|
92
|
+
#
|
93
|
+
# @return [team, nil] the lower of the match, if applicable
|
58
94
|
def get_match_loser(match)
|
59
95
|
winner = get_match_winner(match)
|
96
|
+
|
97
|
+
return nil unless winner
|
60
98
|
get_match_teams(match).reject { |team| team == winner }.first
|
61
99
|
end
|
62
100
|
|
101
|
+
# Get a hash of unique team pairs and their number of occurences. Used by
|
102
|
+
# tournament systems.
|
103
|
+
#
|
104
|
+
# @return [Hash{Set(team, team) => Integer}]
|
105
|
+
def matches_hash
|
106
|
+
@matches_hash ||= build_matches_hash
|
107
|
+
end
|
108
|
+
|
109
|
+
# Count the number of times each pair of teams has played already. Used by
|
110
|
+
# tournament systems.
|
111
|
+
#
|
112
|
+
# @param matches [Array<match>]
|
113
|
+
# @return [Integer] the number of duplicate matches
|
114
|
+
def count_duplicate_matches(matches)
|
115
|
+
matches.map { |match| matches_hash[Set.new match] }.reduce(0, :+)
|
116
|
+
end
|
117
|
+
|
118
|
+
# Create a match. Used by tournament systems.
|
119
|
+
#
|
120
|
+
# Specially handles byes, swapping home/away if required.
|
121
|
+
#
|
122
|
+
# @param home_team [team, nil]
|
123
|
+
# @param away_team [team, nil]
|
124
|
+
# @return [void]
|
125
|
+
# @raise when both teams are +nil+
|
63
126
|
def create_match(home_team, away_team)
|
64
127
|
home_team, away_team = away_team, home_team unless home_team
|
65
128
|
raise 'Invalid match' unless home_team
|
66
129
|
|
67
130
|
build_match(home_team, away_team)
|
68
131
|
end
|
132
|
+
|
133
|
+
# Create a bunch of matches. Used by tournament systems.
|
134
|
+
# @see #create_match
|
135
|
+
#
|
136
|
+
# @param matches [Array<Array(team, team)>] a collection of pairs
|
137
|
+
# @return [void]
|
138
|
+
def create_matches(matches)
|
139
|
+
matches.each do |home_team, away_team|
|
140
|
+
create_match(home_team, away_team)
|
141
|
+
end
|
142
|
+
end
|
143
|
+
|
144
|
+
# Get a hash of the scores of all ranked teams. Used by tournament systems.
|
145
|
+
#
|
146
|
+
# @return [Hash{team => Number}] a mapping from teams to scores
|
147
|
+
def scores_hash
|
148
|
+
@scores_hash = ranked_teams.map { |team| [team, get_team_score(team)] }
|
149
|
+
.to_h
|
150
|
+
end
|
151
|
+
|
152
|
+
private
|
153
|
+
|
154
|
+
def build_matches_hash
|
155
|
+
matches.each_with_object(Hash.new(0)) do |match, counter|
|
156
|
+
match = Set.new get_match_teams(match)
|
157
|
+
counter[match] += 1
|
158
|
+
end
|
159
|
+
end
|
69
160
|
end
|
70
161
|
end
|
@@ -1,8 +1,17 @@
|
|
1
|
+
require 'tournament/algorithm/page_playoff'
|
2
|
+
require 'tournament/algorithm/pairers/adjacent'
|
3
|
+
|
1
4
|
module Tournament
|
2
5
|
# Implements the page playoff system.
|
3
6
|
module PagePlayoff
|
4
7
|
extend self
|
5
8
|
|
9
|
+
# Generate matches with the given driver.
|
10
|
+
#
|
11
|
+
# @param driver [Driver]
|
12
|
+
# @option options [Integer] round the round to generate
|
13
|
+
# @option options [Boolean] bronze_match whether to generate a bronze match
|
14
|
+
# on the final round.
|
6
15
|
def generate(driver, options = {})
|
7
16
|
teams = driver.ranked_teams
|
8
17
|
raise 'Page Playoffs only works with 4 teams' if teams.length != 4
|
@@ -18,32 +27,26 @@ module Tournament
|
|
18
27
|
end
|
19
28
|
end
|
20
29
|
|
21
|
-
|
22
|
-
|
30
|
+
# The total number of rounds in a page playoff tournament
|
31
|
+
#
|
32
|
+
# @param _ for keeping the same interface as other tournament systems.
|
33
|
+
# @return [Integer]
|
34
|
+
def total_rounds(_ = nil)
|
35
|
+
Algorithm::PagePlayoff::TOTAL_ROUNDS
|
23
36
|
end
|
24
37
|
|
38
|
+
# Guess the next round number (starting at 0) from the state in a driver.
|
39
|
+
#
|
40
|
+
# @param driver [Driver]
|
41
|
+
# @return [Integer]
|
25
42
|
def guess_round(driver)
|
26
|
-
|
27
|
-
|
28
|
-
case count
|
29
|
-
when 0 then 0
|
30
|
-
when 2 then 1
|
31
|
-
when 3 then 2
|
32
|
-
else
|
33
|
-
raise 'Invalid number of matches'
|
34
|
-
end
|
43
|
+
Algorithm::PagePlayoff.guess_round(driver.matches.length)
|
35
44
|
end
|
36
45
|
|
37
46
|
private
|
38
47
|
|
39
|
-
def create_matches(driver, matches)
|
40
|
-
matches.each do |match|
|
41
|
-
driver.create_match match[0], match[1]
|
42
|
-
end
|
43
|
-
end
|
44
|
-
|
45
48
|
def semi_finals(driver, teams)
|
46
|
-
create_matches
|
49
|
+
driver.create_matches Algorithm::Pairers::Adjacent.pair(teams)
|
47
50
|
end
|
48
51
|
|
49
52
|
def preliminary_finals(driver)
|
@@ -1,58 +1,52 @@
|
|
1
|
+
require 'tournament/algorithm/util'
|
2
|
+
require 'tournament/algorithm/round_robin'
|
3
|
+
|
1
4
|
module Tournament
|
2
5
|
# Implements the round-robin tournament system.
|
3
|
-
# Requires a consistent seeder, defaulting to Seeder::None
|
4
6
|
module RoundRobin
|
5
7
|
extend self
|
6
8
|
|
9
|
+
# Generate matches with the given driver.
|
10
|
+
#
|
11
|
+
# @param driver [Driver]
|
12
|
+
# @option options [Integer] round the round to generate
|
13
|
+
# @return [void]
|
7
14
|
def generate(driver, options = {})
|
8
15
|
round = options[:round] || guess_round(driver)
|
9
16
|
|
10
|
-
teams =
|
17
|
+
teams = Algorithm::Util.padd_teams(driver.seeded_teams)
|
11
18
|
|
12
|
-
|
19
|
+
matches = Algorithm::RoundRobin.round_robin_pairing(teams, round)
|
13
20
|
|
14
|
-
create_matches driver,
|
21
|
+
create_matches driver, matches, round
|
15
22
|
end
|
16
23
|
|
24
|
+
# The total number of rounds needed for a round robin tournament with the
|
25
|
+
# given driver.
|
26
|
+
#
|
27
|
+
# @param driver [Driver]
|
28
|
+
# @return [Integer]
|
17
29
|
def total_rounds(driver)
|
18
|
-
|
30
|
+
Algorithm::RoundRobin.total_rounds(driver.seeded_teams.length)
|
19
31
|
end
|
20
32
|
|
33
|
+
# Guess the next round number (starting at 0) from the state in driver.
|
34
|
+
#
|
35
|
+
# @param driver [Driver]
|
36
|
+
# @return [Integer]
|
21
37
|
def guess_round(driver)
|
22
|
-
|
23
|
-
|
24
|
-
match_count / (team_count(driver) / 2)
|
38
|
+
Algorithm::RoundRobin.guess_round(driver.seeded_teams.length,
|
39
|
+
driver.matches.length)
|
25
40
|
end
|
26
41
|
|
27
42
|
private
|
28
43
|
|
29
|
-
def
|
30
|
-
|
31
|
-
count += 1 if count.odd?
|
32
|
-
count
|
33
|
-
end
|
34
|
-
|
35
|
-
def seed_teams(teams, options)
|
36
|
-
teams << nil if teams.length.odd?
|
37
|
-
|
38
|
-
seeder = options[:seeder] || Seeder::None
|
39
|
-
seeder.seed teams
|
40
|
-
end
|
41
|
-
|
42
|
-
def rotate_to_round(teams, round)
|
43
|
-
rotateable = teams[1..-1]
|
44
|
-
|
45
|
-
[teams[0]] + rotateable.rotate(-round)
|
46
|
-
end
|
47
|
-
|
48
|
-
def create_matches(driver, teams, round)
|
49
|
-
teams[0...teams.length / 2].each_with_index do |home_team, index|
|
50
|
-
away_team = teams[-index - 1]
|
51
|
-
|
44
|
+
def create_matches(driver, matches, round)
|
45
|
+
matches.each do |match|
|
52
46
|
# Alternate home/away
|
53
|
-
|
47
|
+
match = match.reverse if round.odd? && match[0]
|
54
48
|
|
55
|
-
driver.create_match(
|
49
|
+
driver.create_match(*match)
|
56
50
|
end
|
57
51
|
end
|
58
52
|
end
|
@@ -1,62 +1,49 @@
|
|
1
|
+
require 'tournament/algorithm/single_bracket'
|
2
|
+
require 'tournament/algorithm/pairers/adjacent'
|
3
|
+
|
1
4
|
module Tournament
|
2
5
|
# Implements the single bracket elimination tournament system.
|
3
6
|
module SingleElimination
|
4
7
|
extend self
|
5
8
|
|
6
|
-
|
9
|
+
# Generate matches with the given driver
|
10
|
+
#
|
11
|
+
# @param driver [Driver]
|
12
|
+
# @return [void]
|
13
|
+
def generate(driver, _options = {})
|
7
14
|
round = guess_round(driver)
|
8
15
|
|
9
16
|
teams = if driver.matches.empty?
|
10
|
-
|
17
|
+
padded = Algorithm::SingleBracket.padd_teams driver.seeded_teams
|
18
|
+
Algorithm::SingleBracket.seed padded
|
11
19
|
else
|
12
20
|
last_matches = previous_round_matches driver, round
|
13
21
|
get_match_winners driver, last_matches
|
14
22
|
end
|
15
23
|
|
16
|
-
create_matches
|
24
|
+
driver.create_matches Algorithm::Pairers::Adjacent.pair(teams)
|
17
25
|
end
|
18
26
|
|
27
|
+
# The total number of rounds needed for a single elimination tournament with
|
28
|
+
# the given driver.
|
29
|
+
#
|
30
|
+
# @param driver [Driver]
|
31
|
+
# @return [Integer]
|
19
32
|
def total_rounds(driver)
|
20
|
-
|
33
|
+
Algorithm::SingleBracket.total_rounds(driver.seeded_teams.length)
|
21
34
|
end
|
22
35
|
|
36
|
+
# Guess the next round number (starting at 0) from the state in driver.
|
37
|
+
#
|
38
|
+
# @param driver [Driver]
|
39
|
+
# @return [Integer]
|
23
40
|
def guess_round(driver)
|
24
|
-
|
25
|
-
|
26
|
-
matches_count = driver.matches.length
|
27
|
-
|
28
|
-
# Make sure we don't have too many matches
|
29
|
-
raise ArgumentError, 'Too many matches' unless teams_count > matches_count
|
30
|
-
|
31
|
-
round = rounds - Math.log2(teams_count - matches_count)
|
32
|
-
# Make sure we don't have some weird number of matches
|
33
|
-
raise ArgumentError, 'Invalid number of matches' unless (round % 1).zero?
|
34
|
-
round.to_i
|
41
|
+
Algorithm::SingleBracket.guess_round(driver.seeded_teams.length,
|
42
|
+
driver.matches.length)
|
35
43
|
end
|
36
44
|
|
37
45
|
private
|
38
46
|
|
39
|
-
def seed_teams(teams, options)
|
40
|
-
teams = padd_teams teams
|
41
|
-
|
42
|
-
seeder = options[:seeder] || Seeder::SingleBracket
|
43
|
-
seeder.seed teams
|
44
|
-
end
|
45
|
-
|
46
|
-
def padd_teams(teams)
|
47
|
-
required = 2**total_rounds_for_teams(teams)
|
48
|
-
padding = required - teams.length
|
49
|
-
|
50
|
-
# Insert the padding at the bottom to give top teams byes
|
51
|
-
teams + [nil] * padding
|
52
|
-
end
|
53
|
-
|
54
|
-
def total_rounds_for_teams(teams)
|
55
|
-
team_count = teams.length
|
56
|
-
|
57
|
-
Math.log2(team_count).ceil
|
58
|
-
end
|
59
|
-
|
60
47
|
def get_match_winners(driver, matches)
|
61
48
|
matches.map { |match| driver.get_match_winner(match) }
|
62
49
|
end
|
@@ -67,13 +54,5 @@ module Tournament
|
|
67
54
|
|
68
55
|
driver.matches.last(previous_matches_count)
|
69
56
|
end
|
70
|
-
|
71
|
-
def create_matches(driver, teams)
|
72
|
-
teams.each_slice(2) do |slice|
|
73
|
-
next if slice.all?(&:nil?)
|
74
|
-
|
75
|
-
driver.create_match(slice[0], slice[1])
|
76
|
-
end
|
77
|
-
end
|
78
57
|
end
|
79
58
|
end
|