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.
@@ -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
@@ -0,0 +1,6 @@
1
+ module Tournament
2
+ # This module provides abtraction-less implementations of all algorithms
3
+ # used for tournament systems.
4
+ module Algorithm
5
+ end
6
+ end
@@ -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
- # :reek:UnusedParameters
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 playing with their initial seedings
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 playing, ranked by their current position in the tournament
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 both teams playing for a match
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
- # Handle for matches that are created by tournament systems
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
- def total_rounds
22
- 3
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
- count = driver.matches.length
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 driver, [[teams[0], teams[1]], [teams[2], teams[3]]]
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 = seed_teams driver.seeded_teams, options
17
+ teams = Algorithm::Util.padd_teams(driver.seeded_teams)
11
18
 
12
- teams = rotate_to_round teams, round
19
+ matches = Algorithm::RoundRobin.round_robin_pairing(teams, round)
13
20
 
14
- create_matches driver, teams, round
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
- team_count(driver) - 1
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
- match_count = driver.matches.length
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 team_count(driver)
30
- count = driver.seeded_teams.length
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
- home_team, away_team = away_team, home_team if round.odd?
47
+ match = match.reverse if round.odd? && match[0]
54
48
 
55
- driver.create_match(home_team, away_team)
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
- def generate(driver, options = {})
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
- seed_teams driver.seeded_teams, options
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 driver, teams
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
- total_rounds_for_teams(driver.seeded_teams)
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
- rounds = total_rounds(driver)
25
- teams_count = 2**rounds
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