-
Notifications
You must be signed in to change notification settings - Fork 0
/
bracket.rb
299 lines (240 loc) · 11.6 KB
/
bracket.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
# frozen_string_literal: true
class Bracket
attr_reader :players, :config
def initialize(slug, options)
@slug = slug
@api_key = options.api_key
@use_cache = options.use_cache
@update_cache = options.update_cache
@loaded = false
end
def complete?
@state == "complete"
end
# Returns a boolean indicating whether the bracket was loaded.
def load
url = "https://api.challonge.com/v1/tournaments/#{@slug}.json"
params = { include_matches: 1, include_participants: 1 }
begin
response = send_get_request(url, "#{@slug}_tournament.json", params)
rescue RestClient::NotFound
# Bail out if we got a 404 error. The bracket doesn't exist on
# Challonge right now, but it might be created in the future.
puts "Warning: The bracket does not exist."
return false
end
@challonge_bracket = OpenStruct.new(response[:tournament])
@state = @challonge_bracket.state
# Bail out if the bracket hasn't started yet. This lets the tournament
# organizer set the `next_bracket` value to a bracket that has been
# created on Challonge, but which will be started in the future. For
# example, the organizer can create a wild card bracket and a finals
# bracket, and set `next_bracket` in the wild card bracket to the slug
# of the finals bracket before the wild card bracket has finished.
if @challonge_bracket.started_at.nil?
puts "The bracket has not been started yet."
return false
end
read_config
read_teams
read_matches
read_players
@loaded = true
true
end
# Calculates how many points each player has earned in a bracket. If the
# bracket is not yet complete, the point values are the mininum number of
# points that the player can receive based on their current position in
# the bracket.
# On exit, `@players` contains a hash. The keys are the Challonge IDs of
# the teams in the bracket. The values are arrays of `Player` objects
# representing the players on the team.
def calculate_points
raise "The bracket was not loaded" if !@loaded
calculate_team_points
calculate_player_points
end
protected
def read_config
# Find the match that has the config file attached to it. By convention,
# the file is attached to the first match, although we don't enforce that.
# We just look for a match with exactly 1 attachment.
first_match = @challonge_bracket.matches.select do |match|
match[:match][:attachment_count] == 1
end
raise "No matches with one attachment were found in the bracket" if first_match.empty?
raise "Multiple matches have one attachment" if first_match.size > 1
# Read the options from the config file that's attached to that match.
url = "https://api.challonge.com/v1/tournaments/#{@slug}/matches/" \
"#{first_match[0][:match][:id]}/attachments.json"
attachment_list = send_get_request(url, "#{@slug}_attachments.json")
asset_url = attachment_list[0][:match_attachment][:asset_url]
raise "Couldn't find the config file attachment" if asset_url.nil?
uri = URI(asset_url)
# The attachment URLs that Challonge returns don't have a scheme, and
# instead start with "//". Default to HTTPS.
uri.scheme ||= "https"
puts "Reading the config file from #{uri}"
config = send_get_request(uri.to_s, "#{@slug}_config_file.json")
%i(base_point_value max_players_to_count match_values).each do |key|
raise "The config file is missing \"#{key}\"" unless config.key?(key)
end
@config = Config.new(config)
end
def read_teams
@teams = []
@challonge_bracket.participants.each do |team|
@teams << Team.new(team[:participant])
end
puts "#{@teams.size} teams are in the bracket: " +
@teams.sort_by(&:name).map { |t| %("#{t.name}") }.join(", ")
# Check that all of the teams in the bracket are also in the config file.
missing_teams = []
config_team_names = @config.teams.map { |t| t[:name] }
@teams.each do |team|
if config_team_names.none? { |name| name.casecmp?(team.name) }
missing_teams << team.name
end
end
if missing_teams.any?
raise "These teams are in the bracket but not the config file: " +
missing_teams.join(", ")
end
end
def read_matches
# Check that `match_values` in the config file is the right size.
# The size must normally equal the number of matches. However, if the
# bracket is complete (finalized, or not finalized but all matches have
# been played) and it is double-elimination, then the array size is
# allowed to be one more than the number of matches, to account for a grand
# final that was only one match long.
#
# If this is a two-stage bracket, the matches in the first stage have
# `suggested_play_order` set to nil, so don't consider those matches.
# If there is a match for 3rd place, its `suggested_play_order` is nil.
# We also ignore that match, and instead, assign points to the 3rd-place
# and 4th-place teams after the bracket has finished.
@matches = []
elim_stage_matches =
@challonge_bracket.matches.select { |m| m[:match][:suggested_play_order] }
num_matches = elim_stage_matches.size
array_size = @config.match_values.size
if num_matches != array_size
if (@state != "complete" && @state != "awaiting_review") ||
@challonge_bracket.tournament_type != "double elimination" ||
array_size != num_matches + 1
raise "match_values in the config file is the wrong size." \
" The size is #{array_size}, expected #{num_matches}."
end
end
elim_stage_matches.each do |match|
@matches << Match.new(match[:match], @config.match_values)
end
end
def read_players
@players = {}
# Parse the team list and create structs for each player on the teams.
@config.teams.each do |team|
# Look up the team in the `teams` hash. This is how we associate a
# team in the config file with its ID on Challonge.
team_obj = @teams.find { |t| t.name.casecmp?(team[:name]) }
# If the `find` call failed, then there is a team in the team list that
# isn't in the bracket. We allow this so that multiple brackets can
# use the same master team list during a tournament.
if team_obj.nil?
puts "Skipping a team that isn't in the bracket: #{team[:name]}"
next
end
@players[team_obj.id] = []
team[:players].each do |player|
@players[team_obj.id] << Player.new(player)
end
puts "#{team[:name]} (ID #{team_obj.id}) has: " +
@players[team_obj.id].map { |p| "#{p.name} (#{p.scene})" }.join(", ")
end
# Bail out if any team doesn't have exactly 5 players.
invalid_teams = @players.select do |_, team|
team.size != 5
end.each_key.map do |team_id|
@teams.find { |t| t.id == team_id }.name
end
if invalid_teams.any?
raise "These teams don't have 5 players: #{invalid_teams.join(', ')}"
end
end
# Calculates how many points each team has earned in a bracket. If the
# bracket is not yet complete, the values are the mininum number of points
# that the team can receive based on their current position in the bracket.
def calculate_team_points
# If the bracket is complete, we can calculate points based on the
# teams' `final_rank`s.
if @state == "complete"
calculate_team_points_by_final_rank
return
end
# For each team, look at the matches that it is in, look at the point
# values of those matches, and take the maximum point value. That's the
# number of points that the team has earned so far in the bracket.
base_point_value = @config.base_point_value
@teams.each do |team|
matches_with_team = @matches.select { |match| match.has_team?(team.id) }
puts "Team #{team.name} was in #{matches_with_team.size} matches"
points_earned = matches_with_team.max_by(&:points).points
puts "The largest point value of those matches is #{points_earned}" \
"#{" + #{base_point_value} base" if base_point_value > 0}"
team.points = points_earned + base_point_value
end
end
# Calculates how many points each player has earned in the tournament.
def calculate_player_points
# Sort the teams by points in descending order. This way, the output will
# follow the teams' finishing order, which will be easier to read.
@teams.sort_by(&:points).reverse_each do |team|
puts "Awarding #{team.points} points to #{team.name}: " +
@players[team.id].map(&:to_s).join(", ")
@players[team.id].each do |player|
player.points = team.points
end
end
end
# Calculates how many points each team earned in the bracket.
def calculate_team_points_by_final_rank
# Calculate how many points to award to each rank. When multiple teams
# have the same rank (e.g., two teams tie for 5th place), those teams
# get the average of the points available to those ranks. For example,
# in a 6-team bracket, the teams in 1st through 4th place get 6 through 3
# points respectively. The two teams in 5th get 1.5, the average of 2 and 1.
sorted_teams = @teams.sort_by(&:final_rank)
num_teams = sorted_teams.size.to_f
final_rank_points = sorted_teams.each_with_index.
each_with_object({}) do |(team, idx), rank_points|
rank_points[team.final_rank] ||= []
rank_points[team.final_rank] << num_teams - idx
end
base_point_value = @config.base_point_value
sorted_teams.each do |team|
points_earned = final_rank_points[team.final_rank].sum /
final_rank_points[team.final_rank].size
puts "#{team.name} finished in position #{team.final_rank} and gets" \
" #{points_earned} points" \
"#{" + #{base_point_value} base" if base_point_value > 0}"
team.points = points_earned + base_point_value
end
end
# Sends a GET request to `url`, treats the returned data as JSON, parses it
# into an object, and returns that object.
# If `cache_file` exists and `USE_CACHE` is true, then `cache_file` will be
# read instead.
def send_get_request(url, cache_file, params = {})
cached_response_file = "cache_#{cache_file}"
params[:api_key] = @api_key
if @use_cache && File.exist?(cached_response_file)
puts "Using the cached response from #{cached_response_file}"
JSON.parse(IO.read(cached_response_file), symbolize_names: true)
else
resp = RestClient.get(url, params: params)
IO.write(cached_response_file, resp) if @update_cache
JSON.parse(resp, symbolize_names: true)
end
end
end