icu_tournament 0.8.9

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,191 @@
1
+ # encoding: utf-8
2
+
3
+ module ICU
4
+
5
+ =begin rdoc
6
+
7
+ == Result
8
+
9
+ A result is the outcome of a game from the perspective of one of the players.
10
+ If the game was not a bye or a walkover and involved a second player, then
11
+ that second player will also have a result for the same game, and the two
12
+ results will be mirror images of each other.
13
+
14
+ A result always involves a round number, a player number and a score, so these
15
+ three attributes must be supplied in the constructor.
16
+
17
+ result = ICU::Result.new(2, 10, 'W')
18
+
19
+ The above example represents player 10 winning in round 2. As it stands, it represends
20
+ a bye or walkover since there is no opponent. Without an opponent, it is unrateable.
21
+
22
+ result.rateable # => false
23
+
24
+ The player's colour and the number of their opponent can be set as follows:
25
+
26
+ result.colour = 'B'
27
+ result.opponent = 13
28
+
29
+ Specifying an opponent always makes a result rateable.
30
+
31
+ result.rateable # => true
32
+
33
+ This example now represents a win by player 10 with the black pieces over player number 13 in round 2.
34
+ Alternatively, all this can been specified in the constructor.
35
+
36
+ result = ICU::Result.new(2, 10, 'W', :opponent => 13, :colour => 'B')
37
+
38
+ To make a game unratable, even if it involves an opponent, set the _rateable_ atribute explicity:
39
+
40
+ result.rateable = false
41
+
42
+ or include it in the constructor:
43
+
44
+ result = ICU::Result.new(2, 10, 'W', :opponent => 13, :colour => 'B', :rateable => false)
45
+
46
+ The result of the same game from the perspective of the opponent is:
47
+
48
+ tluser = result.reverse
49
+
50
+ which, with the above example, would be:
51
+
52
+ tluser.player # => 13
53
+ tluser.opponent # => 10
54
+ tluser.score # => 'L'
55
+ tluser.colour # => 'B'
56
+ tluser.round # => 2
57
+
58
+ The reversed result copies the _rateable_ attribute of the original unless an
59
+ explicit override is supplied.
60
+
61
+ result.rateable # => true
62
+ result.reverse.rateable # => true (copied from original)
63
+ result.reverse(false).rateable # => false (overriden)
64
+
65
+ A result which has no opponent is not reversible (the _reverse_ method returns _nil_).
66
+
67
+ The return value from the _score_ method is always one of _W_, _L_ or _D_. However,
68
+ when setting the score, a certain amount of variation is permitted as long as it is
69
+ clear what is meant. For eample, the following would all be converted to _D_:
70
+
71
+ result.score = ' D '
72
+ result.score = 'd'
73
+ result.score = '='
74
+ result.score = '0.5'
75
+
76
+ The _points_ read-only accessor always returns a floating point number: either 0.0, 0.5 or 1.0.
77
+
78
+ =end
79
+
80
+ class Result
81
+
82
+ extend ICU::Accessor
83
+ attr_positive :round
84
+ attr_integer :player
85
+
86
+ attr_reader :score, :colour, :opponent, :rateable
87
+
88
+ # Constructor. Round number, player number and score must be supplied.
89
+ # Optional hash attribute are _opponent_, _colour_ and _rateable_.
90
+ def initialize(round, player, score, opt={})
91
+ self.round = round
92
+ self.player = player
93
+ self.score = score
94
+ [:colour, :opponent].each { |a| self.send("#{a}=", opt[a]) unless opt[a].nil? }
95
+ self.rateable = opt[:rateable] # always attempt to set this, and do it last, to get the right default
96
+ end
97
+
98
+ # Score for the game, even if a default. One of 'W', 'L' or 'D'. Reasonable inputs like 1, 0, =, ½, etc will be converted.
99
+ def score=(score)
100
+ @score = case score.to_s.strip
101
+ when /^(1\.0|1|\+|W|w)$/ then 'W'
102
+ when /^(0\.5|½|\=|D|d)$/ then 'D'
103
+ when /^(0\.0|0|\-|L|l)$/ then 'L'
104
+ else raise "invalid score (#{score})"
105
+ end
106
+ end
107
+
108
+ # Return the score as a floating point number.
109
+ def points
110
+ case @score
111
+ when 'W' then 1.0
112
+ when 'L' then 0.0
113
+ else 0.5
114
+ end
115
+ end
116
+
117
+ # Colour. Either 'W' (white) or 'B' (black).
118
+ def colour=(colour)
119
+ @colour = case colour.to_s
120
+ when '' then nil
121
+ when /W/i then 'W'
122
+ when /B/i then 'B'
123
+ else raise "invalid colour (#{colour})"
124
+ end
125
+ end
126
+
127
+ # Opponent player number. Either absent (_nil_) or any integer except the player number.
128
+ def opponent=(opponent)
129
+ @opponent = case opponent
130
+ when nil then nil
131
+ when Fixnum then opponent
132
+ when /^\s*$/ then nil
133
+ else opponent.to_i
134
+ end
135
+ raise "invalid opponent number (#{opponent})" if @opponent == 0 && !opponent.to_s.match(/\d/)
136
+ raise "opponent number and player number (#{@opponent}) must be different" if @opponent == player
137
+ self.rateable = true if @opponent
138
+ end
139
+
140
+ # Rateable flag. If false, result is not rateable. Can only be true if there is an opponent.
141
+ def rateable=(rateable)
142
+ if opponent.nil?
143
+ @rateable = false
144
+ return
145
+ end
146
+ @rateable = case rateable
147
+ when nil then true # default is true
148
+ when false then false # this is the only way to turn it off
149
+ else true
150
+ end
151
+ end
152
+
153
+ # Return a reversed version (from the opponent's perspective) of a result.
154
+ def reverse(rateable=nil)
155
+ return unless @opponent
156
+ r = Result.new(@round, @opponent, @score == 'W' ? 'L' : (@score == 'L' ? 'W' : 'D'))
157
+ r.opponent = @player
158
+ r.colour = 'W' if @colour == 'B'
159
+ r.colour = 'B' if @colour == 'W'
160
+ r.rateable = rateable || @rateable
161
+ r
162
+ end
163
+
164
+ # Renumber the player and opponent (if there is one) according to the supplied hash. Return self.
165
+ def renumber(map)
166
+ raise "result player number #{@player} not found in renumbering hash" unless map[@player]
167
+ self.player = map[@player]
168
+ if @opponent
169
+ raise "result opponent number #{@opponent} not found in renumbering hash" unless map[@opponent]
170
+ self.opponent = map[@opponent]
171
+ end
172
+ self
173
+ end
174
+
175
+ # Loose equality. True if the round, player and opponent numbers, colour and score all match.
176
+ def ==(other)
177
+ return unless other.is_a? Result
178
+ [:round, :player, :opponent, :colour, :score].each do |m|
179
+ return false unless self.send(m) == other.send(m)
180
+ end
181
+ true
182
+ end
183
+
184
+ # Strict equality. True if the there's loose equality and also the rateablity is the same.
185
+ def eql?(other)
186
+ return true if equal?(other)
187
+ return false unless self == other
188
+ self.rateable == other.rateable
189
+ end
190
+ end
191
+ end
@@ -0,0 +1,90 @@
1
+ module ICU
2
+
3
+ =begin rdoc
4
+
5
+ == Team
6
+
7
+ A team consists of a name and one or more players referenced by numbers.
8
+ Typically the team will be attached to a tournament (ICU::Tournament)
9
+ and the numbers will the unique numbers by which the players in that
10
+ tournament are referenced. To instantiate a team, you must supply a
11
+ name.
12
+
13
+ team = ICU::Team.new('Wandering Dragons')
14
+
15
+ Then you simply add player's (numbers) to it.
16
+
17
+ team.add_player(1)
18
+ team.add_payeer(3)
19
+ team.add_player(7)
20
+
21
+ To get the current members of a team
22
+
23
+ team.members # => [1, 3, 7]
24
+
25
+ You can enquire whether a team contains a given player number.
26
+
27
+ team.contains?(3) # => true
28
+ team.contains?(4) # => false
29
+
30
+ Or whether it matches a given name (which ignoring case and removing spurious whitespace)
31
+
32
+ team.matches(' wandering dragons ') # => true
33
+ team.matches('Blundering Bishops') # => false
34
+
35
+ Whenever you reset the name of a tournament spurious whitespace is removed but case is not altered.
36
+
37
+ team.name = ' blundering bishops '
38
+ team.name # => "blundering bishops"
39
+
40
+ Attempting to add non-numbers or duplicate numbers as new team members results in an exception.
41
+
42
+ team.add(nil) # exception - not a number
43
+ team.add(3) # exception - already a member
44
+
45
+ =end
46
+
47
+ class Team
48
+
49
+ attr_reader :name, :members
50
+
51
+ # Constructor. Name must be supplied.
52
+ def initialize(name)
53
+ self.name = name
54
+ @members = Array.new
55
+ end
56
+
57
+ # Set name. Must not be blank.
58
+ def name=(name)
59
+ @name = name.strip.squeeze(' ')
60
+ raise "team can't be blank" if @name.length == 0
61
+ end
62
+
63
+ # Add a team member referenced by any integer.
64
+ def add_member(number)
65
+ pnum = number.to_i
66
+ raise "'#{number}' is not a valid as a team member player number" if pnum == 0 && !number.to_s.match(/^[^\d]*0/)
67
+ raise "can't add duplicate player number #{pnum} to team '#{@name}'" if @members.include?(pnum)
68
+ @members.push(pnum)
69
+ end
70
+
71
+ # Renumber the players according to the supplied hash. Return self.
72
+ def renumber(map)
73
+ @members.each_with_index do |pnum, index|
74
+ raise "player number #{pnum} not found in renumbering hash" unless map[pnum]
75
+ @members[index] = map[pnum]
76
+ end
77
+ self
78
+ end
79
+
80
+ # Detect if a member exists in a team.
81
+ def include?(number)
82
+ @members.include?(number)
83
+ end
84
+
85
+ # Does the team name match the given string (ignoring case and spurious whitespace).
86
+ def matches(name)
87
+ self.name.downcase == name.strip.squeeze(' ').downcase
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,508 @@
1
+ module ICU
2
+
3
+ =begin rdoc
4
+
5
+ == Building a Tournament
6
+
7
+ One way to create a tournament object is by parsing one of the supported file type (e.g. ICU::Tournament::Krause).
8
+ It is also possible to build one programmatically by
9
+
10
+ 1. creating a bare tournament instance,
11
+ 2. adding all the players and
12
+ 3. adding all the results.
13
+
14
+ For example:
15
+
16
+ require 'rubygems'
17
+ require 'icu_tournament'
18
+
19
+ t = ICU::Tournament.new('Bangor Masters', '2009-11-09')
20
+
21
+ t.add_player(ICU::Player.new('Bobby', 'Fischer', 10))
22
+ t.add_player(ICU::Player.new('Garry', 'Kasparov', 20))
23
+ t.add_player(ICU::Player.new('Mark', 'Orr', 30))
24
+
25
+ t.add_result(ICU::Result.new(1, 10, 'D', :opponent => 30, :colour => 'W'))
26
+ t.add_result(ICU::Result.new(2, 20, 'W', :opponent => 30, :colour => 'B'))
27
+ t.add_result(ICU::Result.new(3, 20, 'L', :opponent => 10, :colour => 'W'))
28
+
29
+ t.validate!(:rerank => true)
30
+
31
+ and then:
32
+
33
+ serializer = ICU::Tournament::Krause.new
34
+ puts serializer.serialize(@t)
35
+
36
+ or equivalntly, just:
37
+
38
+ puts @t.serialize('Krause')
39
+
40
+ Would result in the following output:
41
+
42
+ 012 Bangor Masters
43
+ 042 2009-11-09
44
+ 001 10 Fischer,Bobby 1.5 1 30 w = 20 b 1
45
+ 001 20 Kasparov,Garry 1.0 2 30 b 1 10 w 0
46
+ 001 30 Orr,Mark 0.5 3 10 b = 20 w 0
47
+
48
+ Note that the players should be added first because the _add_result_ method will
49
+ raise an exception if the players it references through their tournament numbers
50
+ (10, 20 and 30 in this example) have not already been added to the tournament.
51
+
52
+ See ICU::Player and ICU::Result for more details about players and results.
53
+
54
+
55
+ == Validation
56
+
57
+ A tournament can be validated with either the _validate!_ or _invalid_ methods.
58
+ On success, the first returns true while the second returns false.
59
+ On error, the first throws an exception while the second returns a string
60
+ describing the error.
61
+
62
+ Validations checks that:
63
+
64
+ * there are at least two players
65
+ * every player has a least one result
66
+ * the result round numbers are consistent (no more than one game per player per round)
67
+ * the tournament dates (start, finish, round dates), if there are any, are consistent
68
+ * the player ranks are consistent with their scores
69
+
70
+ Side effects of calling _validate!_ or _invalid_ include:
71
+
72
+ * the number of rounds will be set if not set already
73
+ * the finish date will be set if not set already and if there are round dates
74
+
75
+
76
+ == Ranking
77
+
78
+ They players in a tournament can be ranked by calling the _rerank_ method directly.
79
+
80
+ t.rerank
81
+
82
+ Alternatively they can be ranked as a side effect of validation if the _rerank_ option is set,
83
+ but this only applies if the tournament is not yet ranked or it's ranking is inconsistent.
84
+
85
+ t.validate(:rerank => true)
86
+
87
+ Ranking is inconsistent if some but not all players have a rank or if all players
88
+ have a rank but some are ranked higher than others on lower scores.
89
+
90
+ To rank the players requires a tie break method to be specified to order players on the same score.
91
+ The default is alphabetical (by last name then first name). Other methods can be specified by supplying
92
+ a list of methods (strings or symbols) in order of precedence to the _rerank_ method or for the _rerank_
93
+ option of the _validate_ method. Examples:
94
+
95
+ t.rerank('Sonneborn-Berger')
96
+ t.rerank(:buchholz, :neustadtl, :blacks, :wins)
97
+
98
+ t.validate(:rerank => :sonneborn_berger)
99
+ t.validate(:rerank => ['Modified Median', 'Neustadtl', 'Buchholz', 'wins'])
100
+
101
+ The full list of supported methods is:
102
+
103
+ * _Buchholz_: sum of opponents' scores
104
+ * _Harkness_ (or _median_): like Buchholz except the highest and lowest opponents' scores are discarded (or two highest and lowest if 9 rounds or more)
105
+ * _modified_median_: same as Harkness except only lowest (or highest) score(s) are discarded for players with more (or less) than 50%
106
+ * _Neustadtl_ (or _Sonneborn-Berger_): sum of scores of players defeated plus half sum of scores of players drawn against
107
+ * _blacks_: number of blacks
108
+ * _wins_: number of wins
109
+ * _name_: alphabetical by name is the default and is the same as calling _rerank_ with no options or setting the _rerank_ option to true
110
+
111
+ The return value from _rerank_ is the tournament object itself.
112
+
113
+
114
+ == Renumbering
115
+
116
+ The numbers used to uniquely identify each player in a tournament can be any set of unique integers
117
+ (including zero and negative numbers). To renumber the players so that these numbers start at 1 and
118
+ go up to the total number of players use the _renumber_ method. This method takes one optional
119
+ argument to specify how the renumbering is done.
120
+
121
+ t.renumber(:rank) # renumber by rank (if there are consistent rankings), otherwise by name alphabetically
122
+ t.renumber # the same, as renumbering by rank is the default
123
+ t.renumber(:name) # renumber by name alphabetically
124
+
125
+ The return value from _renumber_ is the tournament object itself.
126
+
127
+ =end
128
+
129
+ class Tournament
130
+
131
+ extend ICU::Accessor
132
+ attr_date :start
133
+ attr_date_or_nil :finish
134
+ attr_positive_or_nil :rounds
135
+ attr_string %r%[a-z]%i, :name
136
+ attr_string_or_nil %r%[a-z]%i, :city, :type, :arbiter, :deputy
137
+ attr_string_or_nil %r%[1-9]%i, :time_control
138
+
139
+ attr_reader :round_dates, :site, :fed, :teams
140
+
141
+ # Constructor. Name and start date must be supplied. Other attributes are optional.
142
+ def initialize(name, start, opt={})
143
+ self.name = name
144
+ self.start = start
145
+ [:finish, :rounds, :site, :city, :fed, :type, :arbiter, :deputy, :time_control].each { |a| self.send("#{a}=", opt[a]) unless opt[a].nil? }
146
+ @player = {}
147
+ @teams = []
148
+ @round_dates = []
149
+ end
150
+
151
+ # Set the tournament federation. Can be _nil_.
152
+ def fed=(fed)
153
+ obj = Federation.find(fed)
154
+ @fed = obj ? obj.code : nil
155
+ raise "invalid tournament federation (#{fed})" if @fed.nil? && fed.to_s.strip.length > 0
156
+ end
157
+
158
+ # Add a round date.
159
+ def add_round_date(round_date)
160
+ round_date = round_date.to_s.strip
161
+ parsed_date = Util.parsedate(round_date)
162
+ raise "invalid round date (#{round_date})" unless parsed_date
163
+ @round_dates << parsed_date
164
+ @round_dates.sort!
165
+ end
166
+
167
+ # Return the date of a given round, or nil if unavailable.
168
+ def round_date(round)
169
+ @round_dates[round-1]
170
+ end
171
+
172
+ # Return the greatest round number according to the players results (which may not be the same as the set number of rounds).
173
+ def last_round
174
+ last_round = 0
175
+ @player.values.each do |p|
176
+ p.results.each do |r|
177
+ last_round = r.round if r.round > last_round
178
+ end
179
+ end
180
+ last_round
181
+ end
182
+
183
+ # Set the tournament web site. Should be either unknown (_nil_) or a reasonably valid looking URL.
184
+ def site=(site)
185
+ @site = site.to_s.strip
186
+ @site = nil if @site == ''
187
+ @site = "http://#{@site}" if @site && !@site.match(/^https?:\/\//)
188
+ raise "invalid site (#{site})" unless @site.nil? || @site.match(/^https?:\/\/[-\w]+(\.[-\w]+)+(\/[^\s]*)?$/i)
189
+ end
190
+
191
+ # Add a new team. The argument is either a team (possibly already with members) or the name of a new team.
192
+ # The team's name must be unique in the tournament. Returns the the team instance.
193
+ def add_team(team)
194
+ team = Team.new(team.to_s) unless team.is_a? Team
195
+ raise "a team with a name similar to '#{team.name}' already exists" if self.get_team(team.name)
196
+ @teams << team
197
+ team
198
+ end
199
+
200
+ # Return the team object that matches a given name, or nil if not found.
201
+ def get_team(name)
202
+ @teams.find{ |t| t.matches(name) }
203
+ end
204
+
205
+ # Add a new player to the tournament. Must have a unique player number.
206
+ def add_player(player)
207
+ raise "invalid player" unless player.class == ICU::Player
208
+ raise "player number (#{player.num}) should be unique" if @player[player.num]
209
+ @player[player.num] = player
210
+ end
211
+
212
+ # Get a player by their number.
213
+ def player(num)
214
+ @player[num]
215
+ end
216
+
217
+ # Return an array of all players in order of their player number.
218
+ def players
219
+ @player.values.sort_by{ |p| p.num }
220
+ end
221
+
222
+ # Lookup a player in the tournament by player number, returning _nil_ if the player number does not exist.
223
+ def find_player(player)
224
+ players.find { |p| p == player }
225
+ end
226
+
227
+ # Add a result to a tournament. An exception is raised if the players referenced in the result (by number)
228
+ # do not exist in the tournament. The result, which remember is from the perspective of one of the players,
229
+ # is added to that player's results. Additionally, the reverse of the result is automatically added to the player's
230
+ # opponent, unless the opponent does not exist (e.g. byes, walkovers). By default, if the result is rateable
231
+ # then the opponent's result will also be rateable. To make the opponent's result unrateable, set the optional
232
+ # second parameter to false.
233
+ def add_result(result, reverse_rateable=true)
234
+ raise "invalid result" unless result.class == ICU::Result
235
+ raise "result round number (#{result.round}) inconsistent with number of tournament rounds" if @rounds && result.round > @rounds
236
+ raise "player number (#{result.player}) does not exist" unless @player[result.player]
237
+ @player[result.player].add_result(result)
238
+ if result.opponent
239
+ raise "opponent number (#{result.opponent}) does not exist" unless @player[result.opponent]
240
+ reverse = result.reverse
241
+ reverse.rateable = false unless reverse_rateable
242
+ @player[result.opponent].add_result(reverse)
243
+ end
244
+ end
245
+
246
+ # Rerank the tournament by score first and if necessary using a configurable tie breaker method.
247
+ def rerank(*tie_break_methods)
248
+ tie_break_methods, tie_break_order, tie_break_hash = tie_break_data(tie_break_methods.flatten)
249
+ @player.values.sort do |a,b|
250
+ cmp = 0
251
+ tie_break_methods.each do |m|
252
+ cmp = (tie_break_hash[m][a.num] <=> tie_break_hash[m][b.num]) * tie_break_order[m] if cmp == 0
253
+ end
254
+ cmp
255
+ end.each_with_index do |p,i|
256
+ p.rank = i + 1
257
+ end
258
+ self
259
+ end
260
+
261
+ # Return a hash of tie break scores (player number to value).
262
+ def tie_break_scores(*tie_break_methods)
263
+ tie_break_methods, tie_break_order, tie_break_hash = tie_break_data(tie_break_methods)
264
+ main_method = tie_break_methods[1]
265
+ scores = Hash.new
266
+ @player.values.each { |p| scores[p.num] = tie_break_hash[main_method][p.num] }
267
+ scores
268
+ end
269
+
270
+ # Renumber the players according to a given criterion.
271
+ def renumber(criterion = :rank)
272
+ map = Hash.new
273
+
274
+ # Decide how to renumber.
275
+ criterion = criterion.to_s.downcase
276
+ if criterion.match('rank')
277
+ # Renumber by rank if possible.
278
+ begin check_ranks rescue criterion = 'name' end
279
+ @player.values.each{ |p| map[p.num] = p.rank }
280
+ end
281
+ if !criterion.match('rank')
282
+ # Renumber by name alphabetically.
283
+ @player.values.sort_by{ |p| p.name }.each_with_index{ |p, i| map[p.num] = i + 1 }
284
+ end
285
+
286
+ # Apply renumbering.
287
+ @teams.each{ |t| t.renumber(map) }
288
+ @player = @player.values.inject({}) do |hash, player|
289
+ player.renumber(map)
290
+ hash[player.num] = player
291
+ hash
292
+ end
293
+
294
+ self
295
+ end
296
+
297
+ # Is a tournament invalid? Either returns false (if it's valid) or an error message.
298
+ # Has the same _rerank_ option as validate!.
299
+ def invalid(options={})
300
+ begin
301
+ validate!(options)
302
+ rescue => err
303
+ return err.message
304
+ end
305
+ false
306
+ end
307
+
308
+ # Raise an exception if a tournament is not valid.
309
+ # The _rerank_ option can be set to _true_ or one of the valid tie-break
310
+ # methods to rerank the tournament if ranking is missing or inconsistent.
311
+ def validate!(options={})
312
+ begin check_ranks rescue rerank(options[:rerank]) end if options[:rerank]
313
+ check_players
314
+ check_rounds
315
+ check_dates
316
+ check_teams
317
+ check_ranks(:allow_none => true)
318
+ true
319
+ end
320
+
321
+ # Convenience method to serialise the tournament into a supported format.
322
+ # Throws and exception unless the name of a supported format is supplied (e.g. _Krause_).
323
+ def serialize(format)
324
+ serializer = case format.to_s.downcase
325
+ when 'krause' then ICU::Tournament::Krause.new
326
+ when 'foreigncsv' then ICU::Tournament::ForeignCSV.new
327
+ else raise "unsupported serialisation format: '#{format}'"
328
+ end
329
+ serializer.serialize(self)
330
+ end
331
+
332
+ private
333
+
334
+ # Check players.
335
+ def check_players
336
+ raise "the number of players (#{@player.size}) must be at least 2" if @player.size < 2
337
+ @player.each do |num, p|
338
+ raise "player #{num} has no results" if p.results.size == 0
339
+ p.results.each do |r|
340
+ next unless r.opponent
341
+ raise "opponent #{r.opponent} of player #{num} is not in the tournament" unless @player[r.opponent]
342
+ end
343
+ end
344
+ end
345
+
346
+ # Round should go from 1 to a maximum, there should be at least one result in every round and,
347
+ # if the number of rounds has been set, it should agree with the largest round from the results.
348
+ def check_rounds
349
+ round = Hash.new
350
+ round_last = last_round
351
+ @player.values.each do |p|
352
+ p.results.each do |r|
353
+ round[r.round] = true
354
+ end
355
+ end
356
+ (1..round_last).each { |r| raise "there are no results for round #{r}" unless round[r] }
357
+ if rounds
358
+ raise "declared number of rounds is #{rounds} but there are results in later rounds, such as #{round_last}" if rounds < round_last
359
+ raise "declared number of rounds is #{rounds} but there are no results with rounds greater than #{round_last}" if rounds > round_last
360
+ else
361
+ self.rounds = round_last
362
+ end
363
+ end
364
+
365
+ # Check dates are consistent.
366
+ def check_dates
367
+ raise "start date (#{start}) is after end date (#{finish})" if @start && @finish && @start > @finish
368
+ if @round_dates.size > 0
369
+ raise "the number of round dates (#{@round_dates.size}) does not match the number of rounds (#{@rounds})" unless @round_dates.size == @rounds
370
+ raise "the date of the first round (#{@round_dates[0]}) comes before the start (#{@start}) of the tournament" if @start && @start > @round_dates[0]
371
+ raise "the date of the last round (#{@round_dates[-1]}) comes after the end (#{@finish}) of the tournament" if @finish && @finish < @round_dates[-1]
372
+ @finish = @round_dates[-1] unless @finish
373
+ end
374
+ end
375
+
376
+ # Check teams. Either there are none or:
377
+ # * every team member is a valid player, and
378
+ # * every player is a member of exactly one team.
379
+ def check_teams
380
+ return if @teams.size == 0
381
+ member = Hash.new
382
+ @teams.each do |t|
383
+ t.members.each do |m|
384
+ raise "member #{m} of team '#{t.name}' is not a valid player number for this tournament" unless @player[m]
385
+ raise "member #{m} of team '#{t.name}' is already a member of team #{member[m]}" if member[m]
386
+ member[m] = t.name
387
+ end
388
+ end
389
+ @player.keys.each do |p|
390
+ raise "player #{p} is not a member of any team" unless member[p]
391
+ end
392
+ end
393
+
394
+ # Check if the players ranking is consistent, which will be true if:
395
+ # * every player has a rank
396
+ # * no two players have the same rank
397
+ # * the highest rank is 1
398
+ # * the lowest rank is equal to the total of players
399
+ def check_ranks(options={})
400
+ ranks = Hash.new
401
+ @player.values.each do |p|
402
+ if p.rank
403
+ raise "two players have the same rank #{p.rank}" if ranks[p.rank]
404
+ ranks[p.rank] = p
405
+ end
406
+ end
407
+ return if ranks.size == 0 && options[:allow_none]
408
+ raise "every player has to have a rank" unless ranks.size == @player.size
409
+ by_rank = @player.values.sort{ |a,b| a.rank <=> b.rank}
410
+ raise "the highest rank must be 1" unless by_rank[0].rank == 1
411
+ raise "the lowest rank must be #{ranks.size}" unless by_rank[-1].rank == ranks.size
412
+ if by_rank.size > 1
413
+ (1..by_rank.size-1).each do |i|
414
+ p1 = by_rank[i-1]
415
+ p2 = by_rank[i]
416
+ raise "player #{p1.num} with #{p1.points} points is ranked above player #{p2.num} with #{p2.points} points" if p1.points < p2.points
417
+ end
418
+ end
419
+ end
420
+
421
+ # Return an array of tie break methods and an array of tie break orders (+1 for asc, -1 for desc).
422
+ # The first and most important method is always "score", the last and least important is always "name".
423
+ def tie_break_data(tie_break_methods)
424
+ # Canonicalise the tie break method names.
425
+ tie_break_methods.map! do |m|
426
+ m = m.to_s if m.class == Symbol
427
+ m = m.downcase.gsub(/[-\s]/, '_') if m.class == String
428
+ case m
429
+ when true then 'name'
430
+ when 'sonneborn_berger' then 'neustadtl'
431
+ when 'modified_median' then 'modified'
432
+ when 'median' then 'harkness'
433
+ else m
434
+ end
435
+ end
436
+
437
+ # Check they're all valid.
438
+ tie_break_methods.each { |m| raise "invalid tie break method '#{m}'" unless m.match(/^(blacks|buchholz|harkness|modified|name|neustadtl|wins)$/) }
439
+
440
+ # Construct the arrays and hashes to be returned.
441
+ methods, order, data = Array.new, Hash.new, Hash.new
442
+
443
+ # Score is always the most important.
444
+ methods << 'score'
445
+ order['score'] = -1
446
+
447
+ # Add the configured methods.
448
+ tie_break_methods.each do |m|
449
+ methods << m
450
+ order[m] = -1
451
+ end
452
+
453
+ # Name is included as the last and least important tie breaker unless it's already been added.
454
+ unless methods.include?('name')
455
+ methods << 'name'
456
+ order['name'] = +1
457
+ end
458
+
459
+ # We'll need the number of rounds.
460
+ rounds = last_round
461
+
462
+ # Pre-calculate some scores that are not in themselves tie break score
463
+ # but are needed in the calculation of some of the actual tie-break scores.
464
+ pre_calculated = Array.new
465
+ pre_calculated << 'opp-score' # sum scores where a non-played games counts 0.5
466
+ pre_calculated.each do |m|
467
+ data[m] = Hash.new
468
+ @player.values.each do |p|
469
+ data[m][p.num] = tie_break_score(data, m, p, rounds)
470
+ end
471
+ end
472
+
473
+ # Now calculate all the other scores.
474
+ methods.each do |m|
475
+ next if pre_calculated.include?(m)
476
+ data[m] = Hash.new
477
+ @player.values.each { |p| data[m][p.num] = tie_break_score(data, m, p, rounds) }
478
+ end
479
+
480
+ # Finally, return what we calculated.
481
+ [methods, order, data]
482
+ end
483
+
484
+ # Return a tie break score for a given player and a given tie break method.
485
+ def tie_break_score(hash, method, player, rounds)
486
+ case method
487
+ when 'score' then player.points
488
+ when 'wins' then player.results.inject(0) { |t,r| t + (r.opponent && r.score == 'W' ? 1 : 0) }
489
+ when 'blacks' then player.results.inject(0) { |t,r| t + (r.opponent && r.colour == 'B' ? 1 : 0) }
490
+ when 'buchholz' then player.results.inject(0.0) { |t,r| t + (r.opponent ? hash['opp-score'][r.opponent] : 0.0) }
491
+ when 'neustadtl' then player.results.inject(0.0) { |t,r| t + (r.opponent ? hash['opp-score'][r.opponent] * r.points : 0.0) }
492
+ when 'opp-score' then player.results.inject(0.0) { |t,r| t + (r.opponent ? r.points : 0.5) } + (rounds - player.results.size) * 0.5
493
+ when 'harkness', 'modified'
494
+ scores = player.results.map{ |r| r.opponent ? hash['opp-score'][r.opponent] : 0.0 }.sort
495
+ 1.upto(rounds - player.results.size) { scores << 0.0 }
496
+ half = rounds / 2.0
497
+ times = rounds >= 9 ? 2 : 1
498
+ if method == 'harkness' || player.points == half
499
+ 1.upto(times) { scores.shift; scores.pop }
500
+ else
501
+ 1.upto(times) { scores.send(player.points > half ? :shift : :pop) }
502
+ end
503
+ scores.inject(0.0) { |t,s| t + s }
504
+ else player.name
505
+ end
506
+ end
507
+ end
508
+ end