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.
- 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
|