tournament-system 0.1.4 → 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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