icu_tournament 0.8.9

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