class ICU::Tournament
One way to create a tournament object is by parsing one of the supported file types (e.g. ICU::Tournament::Krause
). It is also possible to build one programmatically by:
-
creating a bare tournament instance,
-
adding all the players,
-
adding all the results.
For example:
require 'icu_tournament' t = ICU::Tournament.new('Bangor Masters', '2009-11-09') t.add_player(ICU::Player.new('Bobby', 'Fischer', 10)) t.add_player(ICU::Player.new('Garry', 'Kasparov', 20)) t.add_player(ICU::Player.new('Mark', 'Orr', 30)) t.add_result(ICU::Result.new(1, 10, 'D', :opponent => 30, :colour => 'W')) t.add_result(ICU::Result.new(2, 20, 'W', :opponent => 30, :colour => 'B')) t.add_result(ICU::Result.new(3, 20, 'L', :opponent => 10, :colour => 'W')) t.validate!(:rerank => true)
and then:
serializer = ICU::Tournament::Krause.new puts serializer.serialize(@t)
or equivalntly, just:
puts t.serialize('Krause')
would result in the following output:
012 Bangor Masters 042 2009-11-09 001 10 Fischer,Bobby 1.5 1 30 w = 20 b 1 001 20 Kasparov,Garry 1.0 2 30 b 1 10 w 0 001 30 Orr,Mark 0.5 3 10 b = 20 w 0
Note that the players should be added first because the add_result method will raise an exception if the players it references through their tournament numbers (10, 20 and 30 in this example) have not already been added to the tournament.
Adding a result from the perspective of one player automatically adds it from the perspective of the opponent, if there is one. The result may subsequently be added explicitly from opponent’s perspective as long as it does not contradict what was implicitly added previously.
t.add_result(ICU::Result.new(3, 20, 'L', :opponent => 10, :colour => 'W')) t.add_result(ICU::Result.new(3, 10, 'W', :opponent => 20, :colour => 'B')) # unnecessary, but not a problem t.add_result(ICU::Result.new(3, 20, 'L', :opponent => 10, :colour => 'W')) t.add_result(ICU::Result.new(3, 10, 'D', :opponent => 20, :colour => 'B')) # would raise an exception
Asymmetric Scores¶ ↑
There is one exception to the rule that two corresponding results must be consistent: if both results are unrateable then the two scores need not sum to 1. The commonest case this caters for is probably that of a double default. To create such asymmetric results you must add the result from both players’ perspectives. For example:
t.add_result(ICU::Result.new(3, 20, 'L', :opponent => 10, :rateable => false)) t.add_result(ICU::Result.new(3, 10, 'L', :opponent => 20, :rateable => false))
After the first add_result the two results are, as usual, consistent (in particular, the loss for player 20 is balanced by a win for player 10). However, the second add_result, which asserts player 10 lost, does not cause an exception. It would have done if the results had been rateable but, because they are not, the scores are allowed to add up to something other than 1.0 (in this case, zero) and the effect of the second call to add_result is merely to adjust the score of player 10 from a win to a loss (while maintaining the loss for player 20).
See ICU::Player
and ICU::Result
for more details about players and results.
Tournament
Dates¶ ↑
A tournament start date is mandatory and supplied in the constructor. Finish and round dates are optional. To supply a finish date, supply it in constructor arguments or set it explicityly.
t = ICU::Tournament.new('Bangor Masters', '2009-11-09', :finish => '2009-11-11') t.finish = '2009-11-11'
To set round dates, add the correct number in the correct order one at a time.
t.add_round_date('2009-11-09') t.add_round_date('2009-11-10') t.add_round_date('2009-11-11')
Validation¶ ↑
A tournament can be validated with either the validate! or invalid methods. On success, the first returns true while the second returns false. On error, the first throws an exception while the second returns a string describing the error.
Validations checks that:
-
there are at least two players
-
result round numbers are consistent (no more than one game per player per round)
-
corresponding results are consistent (although they may have asymmetric scores if unrateable, as previously desribed)
-
the tournament dates (start, finish, round dates), if there are any, are consistent
-
player ranks are consistent with their scores
-
there are no players with duplicate ICU IDs or duplicate FIDE IDs
Side effects of calling validate! or invalid include:
-
the number of rounds will be set if not set already
-
the finish date will be set if not set already and if there are round dates
Optionally, additional validation checks, appropriate for a given serializer, may be performed. For example:
t.validate!(:type => ICU::Tournament.ForeignCSV.new)
or equivalently,
t.validate!(:type => 'ForeignCSV')
which, amongst other tests, checks that there is at least one player with an ICU number and that all such players have a least one game against a FIDE rated opponent. This is an example of a specialized check that is only appropriate for a particular serializer. If it raises an exception then the tournament cannot be serialized that way.
Validation is automatically performed just before a tournament is serialized. For example, the following are equivalent and will throw an exception if the tournament is invalid according to either the general rules or the rules specific for the type used:
t.serialize('ForeignCSV') ICU::Tournament::ForeignCSV.new.serialize(t)
Ranking¶ ↑
The players in a tournament can be ranked by calling the rerank method directly.
t.rerank
Alternatively they can be ranked as a side effect of validation if the rerank option is set, but this only applies if the tournament is not yet ranked or it’s ranking is inconsistent.
t.validate(:rerank => true)
Ranking is inconsistent if not all players have a rank or at least one pair of players exist where one has a higher score but a lower rank.
To rank the players requires one or more tie break methods for ordering players on the same score. Methods can be specified by supplying an array of methods names (strings or symbols) in order of precedence to the tie_breaks setter. Examples:
t.tie_breaks = ['Sonneborn-Berger'] t.tie_breaks = [:buchholz, :neustadtl, :blacks, :wins] t.tie_breaks = [] # use the default - see below
If the first method fails to differentiate two tied players, the second is tried, and then the third and so on. See ICU::TieBreak
for the full list of supported tie break methods.
Unless explicity specified, the name tie break (which orders alphabetically by last name then first name) is implicitly used as a method of last resort. Thus, in the absence of any tie break methods being specified at all, alphabetical ordering is the default.
The return value from rerank is the tournament object itself, to allow method chaining, for example:
t.rerank.renumber
Renumbering¶ ↑
The numbers used to uniquely identify each player in a tournament can be any set of unique integers (including zero and negative numbers). To renumber the players so that these numbers start at 1 and end with the total number of players, use the renumber method. This method takes one optional argument to specify how the renumbering is done.
t.renumber(:rank) # renumber by rank (if there are consistent rankings), otherwise by name alphabetically t.renumber # the same, as renumbering by rank is the default t.renumber(:name) # renumber by name alphabetically t.renumber(:order) # renumber maintaining the order of the original numbers
The return value from renumber is the tournament object itself.
Parsing Files¶ ↑
As an alternative to processing files by first instantiating a parser of the appropropriate class (such as ICU::Tournament::SwissPerfect
, ICU::Tournament::Krause
and ICU::Tournament::ForeignCSV
) and then calling the parser’s parse_file or parse_file! instance method, a convenience class method, parse_file!, is available when a parser instance is not required. For example:
t = ICU::Tournament.parse_file!('champs.zip', 'SwissPerfect', :start => '2010-07-03')
The method takes a filename, format and an options hash as arguments. It either returns an instance of ICU::Tournament
or throws an exception. See the documentation for the different formats for what options are available. For some, no options are available, in which case any options supplied to this method will be silently ignored.
Attributes
Public Class Methods
Constructor. Name and start date must be supplied. Other attributes are optional.
# File lib/icu_tournament/tournament.rb, line 207 def initialize(name, start, opt={}) self.name = name self.start = start [:finish, :rounds, :site, :city, :fed, :type, :arbiter, :deputy, :time_control].each { |a| self.send("#{a}=", opt[a]) unless opt[a].nil? } @player = {} @teams = [] @round_dates = [] @tie_breaks = [] end
Convenience method to parse a file.
# File lib/icu_tournament/tournament.rb, line 445 def self.parse_file!(file, format, opts={}) type = format.to_s raise "Invalid format" unless klass = factory(format) #type.match(/^(SwissPerfect|SPExport|Krause|ForeignCSV)$/); parser = klass.new if type == 'ForeignCSV' # Doesn't take options. parser.parse_file!(file) else # The others can take options. parser.parse_file!(file, opts) end end
Public Instance Methods
Add a new player to the tournament. Must have a unique player number.
# File lib/icu_tournament/tournament.rb, line 281 def add_player(player) raise "invalid player" unless player.class == ICU::Player raise "player number (#{player.num}) should be unique" if @player[player.num] @player[player.num] = player end
Add a result to a tournament. An exception is raised if the players referenced in the result (by number) do not exist in the tournament. The result, which remember is from the perspective of one of the players, is added to that player’s results. Additionally, the reverse of the result is automatically added to the player’s opponent, if there is one.
# File lib/icu_tournament/tournament.rb, line 306 def add_result(result) raise "invalid result" unless result.class == ICU::Result raise "result round number (#{result.round}) inconsistent with number of tournament rounds" if @rounds && result.round > @rounds raise "player number (#{result.player}) does not exist" unless @player[result.player] return if add_asymmetric_result?(result) @player[result.player].add_result(result) if result.opponent raise "opponent number (#{result.opponent}) does not exist" unless @player[result.opponent] @player[result.opponent].add_result(result.reverse) end end
Add a round date.
# File lib/icu_tournament/tournament.rb, line 225 def add_round_date(round_date) round_date = round_date.to_s.strip parsed_date = Util::Date.parse(round_date) raise "invalid round date (#{round_date})" unless parsed_date @round_dates << parsed_date end
Add a new team. The argument is either a team (possibly already with members) or the name of a new team. The team’s name must be unique in the tournament. Returns the the team instance.
# File lib/icu_tournament/tournament.rb, line 258 def add_team(team) team = Team.new(team.to_s) unless team.is_a? Team raise "a team with a name similar to '#{team.name}' already exists" if self.get_team(team.name) @teams << team team end
Set the tournament federation. Can be nil.
# File lib/icu_tournament/tournament.rb, line 218 def fed=(fed) obj = ICU::Federation.find(fed) @fed = obj ? obj.code : nil raise "invalid tournament federation (#{fed})" if @fed.nil? && fed.to_s.strip.length > 0 end
Lookup a player in the tournament by player number, returning nil if the player number does not exist.
# File lib/icu_tournament/tournament.rb, line 298 def find_player(player) players.find { |p| p == player } end
Return the team object that matches a given name, or nil if not found.
# File lib/icu_tournament/tournament.rb, line 266 def get_team(name) @teams.find{ |t| t.matches(name) } end
Make an educated guess at round dates. If only one round, it is on the start date If start and end date match, all rounds are on that day If there are exactly two rounds, round 1 on the start day, round 2 on the finish day If there are between n and 2n rounds in n consecutive days, then
start with one round a day, switch to two rounds a day when that's needed
If there are 7 rounds Saturday - Sunday > 1 week: two on the first 3 weekend dates, one on the final Sunday This covers most Irish tournaments. Returns an empty array if it could not guess
# File lib/icu_tournament/tournament.rb, line 390 def guess_round_dates return [@start] if rounds == 1 return [] if @finish.nil? round_dates = [] start_date = ::Date.parse(@start) finish_date = ::Date.parse(@finish) ndays = (finish_date - start_date).to_i + 1 if ndays == 1 rounds.times { round_dates << start } elsif rounds == 2 round_dates << start round_dates << finish elsif ndays <= rounds and rounds <= ndays * 2 double_rounds = rounds - ndays (0...ndays).each do |r| round_dates << start_date + r if r >= (ndays - double_rounds) round_dates << start_date + r end end elsif rounds == 7 and start_date.wday == 6 and finish_date.wday == 0 and ndays > 7 2.times { round_dates << start_date } 2.times { round_dates << start_date + 1 } 2.times { round_dates << finish_date - 1 } round_dates << finish_date end return round_dates end
Is a tournament invalid? Either returns false (if it’s valid) or an error message. Has the same rerank option as validate!.
# File lib/icu_tournament/tournament.rb, line 422 def invalid(options={}) begin validate!(options) rescue => err return err.message end false end
Return the greatest round number according to the players results (which may not be the same as the set number of rounds).
# File lib/icu_tournament/tournament.rb, line 238 def last_round last_round = 0 @player.values.each do |p| p.results.each do |r| last_round = r.round if r.round > last_round end end last_round end
Get a player by their number.
# File lib/icu_tournament/tournament.rb, line 288 def player(num) @player[num] end
Return an array of all players in order of their player number.
# File lib/icu_tournament/tournament.rb, line 293 def players @player.values.sort_by{ |p| p.num } end
Renumber the players according to a given criterion.
# File lib/icu_tournament/tournament.rb, line 343 def renumber(criterion = :rank) if (criterion.class == Hash) # Undocumentted feature - supply your own hash. map = criterion else # Official way of reordering. map = Hash.new # Renumber by rank only if possible. criterion = criterion.to_s.downcase if criterion == 'rank' begin check_ranks rescue criterion = 'name' end end # Decide how to renumber. if criterion == 'rank' # Renumber by rank. @player.values.each{ |p| map[p.num] = p.rank } elsif criterion == 'order' # Just keep the existing numbers in order. @player.values.sort_by{ |p| p.num }.each_with_index{ |p, i| map[p.num] = i + 1 } else # Renumber by name alphabetically. @player.values.sort_by{ |p| p.name }.each_with_index{ |p, i| map[p.num] = i + 1 } end end # Apply renumbering. @teams.each{ |t| t.renumber(map) } @player = @player.values.inject({}) do |hash, player| player.renumber(map) hash[player.num] = player hash end # Return self for chaining. self end # Make an educated guess at round dates. # If only one round, it is on the start date # If start and end date match, all rounds are on that day # If there are exactly two rounds, round 1 on the start day, round 2 on the finish day # If there are between n and 2n rounds in n consecutive days, then # start with one round a day, switch to two rounds a day when that's needed # If there are 7 rounds Saturday - Sunday > 1 week: two on the first 3 weekend dates, one on the final Sunday # This covers most Irish tournaments. Returns an empty array if it could not guess def guess_round_dates return [@start] if rounds == 1 return [] if @finish.nil? round_dates = [] start_date = ::Date.parse(@start) finish_date = ::Date.parse(@finish) ndays = (finish_date - start_date).to_i + 1 if ndays == 1 rounds.times { round_dates << start } elsif rounds == 2 round_dates << start round_dates << finish elsif ndays <= rounds and rounds <= ndays * 2 double_rounds = rounds - ndays (0...ndays).each do |r| round_dates << start_date + r if r >= (ndays - double_rounds) round_dates << start_date + r end end elsif rounds == 7 and start_date.wday == 6 and finish_date.wday == 0 and ndays > 7 2.times { round_dates << start_date } 2.times { round_dates << start_date + 1 } 2.times { round_dates << finish_date - 1 } round_dates << finish_date end return round_dates end # Is a tournament invalid? Either returns false (if it's valid) or an error message. # Has the same _rerank_ option as validate!. def invalid(options={}) begin validate!(options) rescue => err return err.message end false end # Raise an exception if a tournament is not valid. The _rerank_ option can be set to _true_ # to rank the tournament just prior to the test if ranking data is missing or inconsistent. def validate!(options={}) begin check_ranks rescue rerank end if options[:rerank] check_players check_rounds check_dates check_teams check_ranks(:allow_none => true) check_type(options[:type]) if options[:type] true end # Convenience method to parse a file. def self.parse_file!(file, format, opts={}) type = format.to_s raise "Invalid format" unless klass = factory(format) #type.match(/^(SwissPerfect|SPExport|Krause|ForeignCSV)$/); parser = klass.new if type == 'ForeignCSV' # Doesn't take options. parser.parse_file!(file) else # The others can take options. parser.parse_file!(file, opts) end end # Convenience method to serialise the tournament into a supported format. # Throws an exception unless the name of a supported format is supplied # or if the tournament is unsuitable for serialisation in that format. def serialize(format, arg={}) serializer = case format.to_s.downcase when 'krause' then ICU::Tournament::Krause.new when 'foreigncsv' then ICU::Tournament::ForeignCSV.new when 'spexport' then ICU::Tournament::SPExport.new when '' then raise "no format supplied" else raise "unsupported serialisation format: '#{format}'" end serializer.serialize(self, arg) end # :enddoc: private # Return a class given a format. def self.factory(format) case format when "SwissPerfect" then ICU::Tournament::SwissPerfect when "SPExport" then ICU::Tournament::SPExport when "Krause" then ICU::Tournament::Krause when "ForeignCSV" then ICU::Tournament::ForeignCSV else nil end end # Check players. def check_players raise "the number of players (#{@player.size}) must be at least 2" if @player.size < 2 ids = Hash.new fide_ids = Hash.new @player.each do |num, p| if p.id raise "duplicate ICU IDs, players #{p.num} and #{ids[p.id]}" if ids[p.id] ids[p.id] = num end if p.fide_id raise "duplicate FIDE IDs, players #{p.num} and #{fide_ids[p.fide_id]}" if fide_ids[p.fide_id] fide_ids[p.fide_id] = num end return if p.results.size == 0 p.results.each do |r| next unless r.opponent opponent = @player[r.opponent] raise "opponent #{r.opponent} of player #{num} is not in the tournament" unless opponent o = opponent.find_result(r.round) raise "opponent #{r.opponent} of player #{num} has no result in round #{r.round}" unless o score = r.rateable || o.rateable ? [] : [:score] raise "opponent's result (#{o.inspect}) is not reverse of player's (#{r.inspect})" unless o.reverse.eql?(r, :except => score) end end end # Round should go from 1 to a maximum, there should be at least one result in every round and, # if the number of rounds has been set, it should agree with the largest round from the results. def check_rounds round = Hash.new round_last = last_round @player.values.each do |p| p.results.each do |r| round[r.round] = true end end (1..round_last).each { |r| raise "there are no results for round #{r}" unless round[r] } if rounds raise "declared number of rounds is #{rounds} but there are results in later rounds, such as #{round_last}" if rounds < round_last raise "declared number of rounds is #{rounds} but there are no results with rounds greater than #{round_last}" if rounds > round_last else self.rounds = round_last end end # Check dates are consistent. def check_dates raise "start date (#{start}) is after end date (#{finish})" if @start && @finish && @start > @finish if @round_dates.size > 0 raise "the number of round dates (#{@round_dates.size}) does not match the number of rounds (#{@rounds})" unless @round_dates.size == @rounds raise "the date of the first round (#{@round_dates[0]}) does not match the start (#{@start}) of the tournament" if @start && @start != @round_dates[0] raise "the date of the last round (#{@round_dates[-1]}) does not match the end (#{@finish}) of the tournament" if @finish && @finish != @round_dates[-1] (2..@round_dates.size).to_a.each do |r| #puts "#{@round_dates[r-2]} => #{@round_dates[r-1]}" raise "the date of round #{r-1} (#{@round_dates[r-2]}) is after the date of round #{r} (#{@round_dates[r-1]}) of the tournament" if @round_dates[r-2] > @round_dates[r-1] end @finish = @round_dates[-1] unless @finish end end # Check teams. Either there are none or: # * every team member is a valid player, and # * every player is a member of exactly one team. def check_teams return if @teams.size == 0 member = Hash.new @teams.each do |t| t.members.each do |m| raise "member #{m} of team '#{t.name}' is not a valid player number for this tournament" unless @player[m] raise "member #{m} of team '#{t.name}' is already a member of team #{member[m]}" if member[m] member[m] = t.name end end @player.keys.each do |p| raise "player #{p} is not a member of any team" unless member[p] end end # Check if the players ranking is consistent, which will be true if: # * every player has a rank # * no two players have the same rank # * the highest rank is 1 # * the lowest rank is equal to the total of players def check_ranks(options={}) ranks = Hash.new @player.values.each do |p| if p.rank raise "two players have the same rank #{p.rank}" if ranks[p.rank] ranks[p.rank] = p end end return if ranks.size == 0 && options[:allow_none] raise "every player has to have a rank" unless ranks.size == @player.size by_rank = @player.values.sort{ |a,b| a.rank <=> b.rank} raise "the highest rank must be 1" unless by_rank[0].rank == 1 raise "the lowest rank must be #{ranks.size}" unless by_rank[-1].rank == ranks.size if by_rank.size > 1 (1..by_rank.size-1).each do |i| p1 = by_rank[i-1] p2 = by_rank[i] raise "player #{p1.num} with #{p1.points} points is ranked above player #{p2.num} with #{p2.points} points" if p1.points < p2.points end end end # Validate against a specific type. def check_type(type) if type.respond_to?(:validate!) type.validate!(self) elsif klass = self.class.factory(type.to_s) klass.new.validate!(self) else raise "invalid type supplied for validation check" end end # Return an array of tie break rules and an array of tie break orders (+1 for asc, -1 for desc). # The first and most important method is always "score", the last and least important is always "name". def tie_break_data # Construct the arrays and hashes to be returned. methods, order, data = Array.new, Hash.new, Hash.new # Score is always the most important. methods << :score order[:score] = -1 # Add the configured methods. tie_breaks.each do |m| methods << m order[m] = m == :name ? 1 : -1 end # Name is included as the last and least important tie breaker unless it's already been added. unless methods.include?(:name) methods << :name order[:name] = 1 end # We'll need the number of rounds. rounds = last_round # Pre-calculate some scores that are not in themselves tie break scores # but are needed in the calculation of some of the actual tie-break scores. pre_calculated = Array.new pre_calculated << :opp_score # sum scores where a non-played games counts 0.5 pre_calculated.each do |m| data[m] = Hash.new @player.values.each { |p| data[m][p.num] = tie_break_score(data, m, p, rounds) } end # Now calculate all the other scores. methods.each do |m| next if pre_calculated.include?(m) data[m] = Hash.new @player.values.each { |p| data[m][p.num] = tie_break_score(data, m, p, rounds) } end # Finally, return what we calculated. [methods, order, data] end # Return a tie break score for a given player and a given tie break method. def tie_break_score(hash, method, player, rounds) case method when :score then player.points when :wins then player.results.inject(0) { |t,r| t + (r.opponent && r.score == 'W' ? 1 : 0) } when :blacks then player.results.inject(0) { |t,r| t + (r.opponent && r.colour == 'B' ? 1 : 0) } when :buchholz then player.results.inject(0.0) { |t,r| t + (r.opponent ? hash[:opp_score][r.opponent] : 0.0) } when :neustadtl then player.results.inject(0.0) { |t,r| t + (r.opponent ? hash[:opp_score][r.opponent] * r.points : 0.0) } when :opp_score then player.results.inject(0.0) { |t,r| t + (r.opponent ? r.points : 0.5) } + (rounds - player.results.size) * 0.5 when :progressive then (1..rounds).inject(0.0) { |t,n| r = player.find_result(n); s = r ? r.points : 0.0; t + s * (rounds + 1 - n) } when :ratings then player.results.inject(0) { |t,r| t + (r.opponent && (@player[r.opponent].fide_rating || @player[r.opponent].rating) ? (@player[r.opponent].fide_rating || @player[r.opponent].rating) : 0) } when :harkness, :modified_median scores = player.results.map{ |r| r.opponent ? hash[:opp_score][r.opponent] : 0.0 }.sort 1.upto(rounds - player.results.size) { scores << 0.0 } half = rounds / 2.0 times = rounds >= 9 ? 2 : 1 if method == :harkness || player.points == half 1.upto(times) { scores.shift; scores.pop } else 1.upto(times) { scores.send(player.points > half ? :shift : :pop) } end scores.inject(0.0) { |t,s| t + s } else player.name end end # Detect when an asymmetric result is about to be added, make the appropriate adjustment and return true. # The conditions for an asymric result are: the player's result already exists, the opponent's result # already exists, both results are unrateable and the reverse of one result is equal to the other, apart # from score. In this case all we do update score of the player's result, thus allowing two results whose # total score does not add to 1. def add_asymmetric_result?(result) return false if result.rateable plr = @player[result.player] opp = @player[result.opponent] return false unless plr && opp plr_result = plr.find_result(result.round) opp_result = opp.find_result(result.round) return false unless plr_result && opp_result return false if plr_result.rateable || opp_result.rateable reversed = plr_result.reverse return false unless reversed && reversed.eql?(opp_result, :except => :score) plr_result.score = result.score true end end end
Rerank the tournament by score first and if necessary using a configurable tie breaker method.
# File lib/icu_tournament/tournament.rb, line 319 def rerank tie_break_methods, tie_break_order, tie_break_hash = tie_break_data @player.values.sort do |a,b| cmp = 0 tie_break_methods.each do |m| cmp = (tie_break_hash[m][a.num] <=> tie_break_hash[m][b.num]) * tie_break_order[m] if cmp == 0 end cmp end.each_with_index do |p,i| p.rank = i + 1 end self end
Return the date of a given round, or nil if unavailable.
# File lib/icu_tournament/tournament.rb, line 233 def round_date(round) @round_dates[round-1] end
Convenience method to serialise the tournament into a supported format. Throws an exception unless the name of a supported format is supplied or if the tournament is unsuitable for serialisation in that format.
# File lib/icu_tournament/tournament.rb, line 461 def serialize(format, arg={}) serializer = case format.to_s.downcase when 'krause' then ICU::Tournament::Krause.new when 'foreigncsv' then ICU::Tournament::ForeignCSV.new when 'spexport' then ICU::Tournament::SPExport.new when '' then raise "no format supplied" else raise "unsupported serialisation format: '#{format}'" end serializer.serialize(self, arg) end
Set the tournament web site. Should be either unknown (nil) or a reasonably valid looking URL.
# File lib/icu_tournament/tournament.rb, line 249 def site=(site) @site = site.to_s.strip @site = nil if @site == '' @site = "http://#{@site}" if @site && !@site.match(/^https?:\/\//) raise "invalid site (#{site})" unless @site.nil? || @site.match(/^https?:\/\/[-\w]+(\.[-\w]+)+(\/[^\s]*)?$/i) end
Return a hash (player number to value) of tie break scores for the main method.
# File lib/icu_tournament/tournament.rb, line 334 def tie_break_scores tie_break_methods, tie_break_order, tie_break_hash = tie_break_data main_method = tie_break_methods[1] scores = Hash.new @player.values.each { |p| scores[p.num] = tie_break_hash[main_method][p.num] } scores end
Canonicalise the names in the tie break array.
# File lib/icu_tournament/tournament.rb, line 271 def tie_breaks=(tie_breaks) raise "argument error - always set tie breaks to an array" unless tie_breaks.class == Array @tie_breaks = tie_breaks.map do |str| tb = ICU::TieBreak.identify(str) raise "invalid tie break method '#{str}'" unless tb tb.id end end
Raise an exception if a tournament is not valid. The rerank option can be set to true to rank the tournament just prior to the test if ranking data is missing or inconsistent.
# File lib/icu_tournament/tournament.rb, line 433 def validate!(options={}) begin check_ranks rescue rerank end if options[:rerank] check_players check_rounds check_dates check_teams check_ranks(:allow_none => true) check_type(options[:type]) if options[:type] true end # Convenience method to parse a file. def self.parse_file!(file, format, opts={}) type = format.to_s raise "Invalid format" unless klass = factory(format) #type.match(/^(SwissPerfect|SPExport|Krause|ForeignCSV)$/); parser = klass.new if type == 'ForeignCSV' # Doesn't take options. parser.parse_file!(file) else # The others can take options. parser.parse_file!(file, opts) end end # Convenience method to serialise the tournament into a supported format. # Throws an exception unless the name of a supported format is supplied # or if the tournament is unsuitable for serialisation in that format. def serialize(format, arg={}) serializer = case format.to_s.downcase when 'krause' then ICU::Tournament::Krause.new when 'foreigncsv' then ICU::Tournament::ForeignCSV.new when 'spexport' then ICU::Tournament::SPExport.new when '' then raise "no format supplied" else raise "unsupported serialisation format: '#{format}'" end serializer.serialize(self, arg) end # :enddoc: private # Return a class given a format. def self.factory(format) case format when "SwissPerfect" then ICU::Tournament::SwissPerfect when "SPExport" then ICU::Tournament::SPExport when "Krause" then ICU::Tournament::Krause when "ForeignCSV" then ICU::Tournament::ForeignCSV else nil end end # Check players. def check_players raise "the number of players (#{@player.size}) must be at least 2" if @player.size < 2 ids = Hash.new fide_ids = Hash.new @player.each do |num, p| if p.id raise "duplicate ICU IDs, players #{p.num} and #{ids[p.id]}" if ids[p.id] ids[p.id] = num end if p.fide_id raise "duplicate FIDE IDs, players #{p.num} and #{fide_ids[p.fide_id]}" if fide_ids[p.fide_id] fide_ids[p.fide_id] = num end return if p.results.size == 0 p.results.each do |r| next unless r.opponent opponent = @player[r.opponent] raise "opponent #{r.opponent} of player #{num} is not in the tournament" unless opponent o = opponent.find_result(r.round) raise "opponent #{r.opponent} of player #{num} has no result in round #{r.round}" unless o score = r.rateable || o.rateable ? [] : [:score] raise "opponent's result (#{o.inspect}) is not reverse of player's (#{r.inspect})" unless o.reverse.eql?(r, :except => score) end end end # Round should go from 1 to a maximum, there should be at least one result in every round and, # if the number of rounds has been set, it should agree with the largest round from the results. def check_rounds round = Hash.new round_last = last_round @player.values.each do |p| p.results.each do |r| round[r.round] = true end end (1..round_last).each { |r| raise "there are no results for round #{r}" unless round[r] } if rounds raise "declared number of rounds is #{rounds} but there are results in later rounds, such as #{round_last}" if rounds < round_last raise "declared number of rounds is #{rounds} but there are no results with rounds greater than #{round_last}" if rounds > round_last else self.rounds = round_last end end # Check dates are consistent. def check_dates raise "start date (#{start}) is after end date (#{finish})" if @start && @finish && @start > @finish if @round_dates.size > 0 raise "the number of round dates (#{@round_dates.size}) does not match the number of rounds (#{@rounds})" unless @round_dates.size == @rounds raise "the date of the first round (#{@round_dates[0]}) does not match the start (#{@start}) of the tournament" if @start && @start != @round_dates[0] raise "the date of the last round (#{@round_dates[-1]}) does not match the end (#{@finish}) of the tournament" if @finish && @finish != @round_dates[-1] (2..@round_dates.size).to_a.each do |r| #puts "#{@round_dates[r-2]} => #{@round_dates[r-1]}" raise "the date of round #{r-1} (#{@round_dates[r-2]}) is after the date of round #{r} (#{@round_dates[r-1]}) of the tournament" if @round_dates[r-2] > @round_dates[r-1] end @finish = @round_dates[-1] unless @finish end end # Check teams. Either there are none or: # * every team member is a valid player, and # * every player is a member of exactly one team. def check_teams return if @teams.size == 0 member = Hash.new @teams.each do |t| t.members.each do |m| raise "member #{m} of team '#{t.name}' is not a valid player number for this tournament" unless @player[m] raise "member #{m} of team '#{t.name}' is already a member of team #{member[m]}" if member[m] member[m] = t.name end end @player.keys.each do |p| raise "player #{p} is not a member of any team" unless member[p] end end # Check if the players ranking is consistent, which will be true if: # * every player has a rank # * no two players have the same rank # * the highest rank is 1 # * the lowest rank is equal to the total of players def check_ranks(options={}) ranks = Hash.new @player.values.each do |p| if p.rank raise "two players have the same rank #{p.rank}" if ranks[p.rank] ranks[p.rank] = p end end return if ranks.size == 0 && options[:allow_none] raise "every player has to have a rank" unless ranks.size == @player.size by_rank = @player.values.sort{ |a,b| a.rank <=> b.rank} raise "the highest rank must be 1" unless by_rank[0].rank == 1 raise "the lowest rank must be #{ranks.size}" unless by_rank[-1].rank == ranks.size if by_rank.size > 1 (1..by_rank.size-1).each do |i| p1 = by_rank[i-1] p2 = by_rank[i] raise "player #{p1.num} with #{p1.points} points is ranked above player #{p2.num} with #{p2.points} points" if p1.points < p2.points end end end # Validate against a specific type. def check_type(type) if type.respond_to?(:validate!) type.validate!(self) elsif klass = self.class.factory(type.to_s) klass.new.validate!(self) else raise "invalid type supplied for validation check" end end # Return an array of tie break rules and an array of tie break orders (+1 for asc, -1 for desc). # The first and most important method is always "score", the last and least important is always "name". def tie_break_data # Construct the arrays and hashes to be returned. methods, order, data = Array.new, Hash.new, Hash.new # Score is always the most important. methods << :score order[:score] = -1 # Add the configured methods. tie_breaks.each do |m| methods << m order[m] = m == :name ? 1 : -1 end # Name is included as the last and least important tie breaker unless it's already been added. unless methods.include?(:name) methods << :name order[:name] = 1 end # We'll need the number of rounds. rounds = last_round # Pre-calculate some scores that are not in themselves tie break scores # but are needed in the calculation of some of the actual tie-break scores. pre_calculated = Array.new pre_calculated << :opp_score # sum scores where a non-played games counts 0.5 pre_calculated.each do |m| data[m] = Hash.new @player.values.each { |p| data[m][p.num] = tie_break_score(data, m, p, rounds) } end # Now calculate all the other scores. methods.each do |m| next if pre_calculated.include?(m) data[m] = Hash.new @player.values.each { |p| data[m][p.num] = tie_break_score(data, m, p, rounds) } end # Finally, return what we calculated. [methods, order, data] end # Return a tie break score for a given player and a given tie break method. def tie_break_score(hash, method, player, rounds) case method when :score then player.points when :wins then player.results.inject(0) { |t,r| t + (r.opponent && r.score == 'W' ? 1 : 0) } when :blacks then player.results.inject(0) { |t,r| t + (r.opponent && r.colour == 'B' ? 1 : 0) } when :buchholz then player.results.inject(0.0) { |t,r| t + (r.opponent ? hash[:opp_score][r.opponent] : 0.0) } when :neustadtl then player.results.inject(0.0) { |t,r| t + (r.opponent ? hash[:opp_score][r.opponent] * r.points : 0.0) } when :opp_score then player.results.inject(0.0) { |t,r| t + (r.opponent ? r.points : 0.5) } + (rounds - player.results.size) * 0.5 when :progressive then (1..rounds).inject(0.0) { |t,n| r = player.find_result(n); s = r ? r.points : 0.0; t + s * (rounds + 1 - n) } when :ratings then player.results.inject(0) { |t,r| t + (r.opponent && (@player[r.opponent].fide_rating || @player[r.opponent].rating) ? (@player[r.opponent].fide_rating || @player[r.opponent].rating) : 0) } when :harkness, :modified_median scores = player.results.map{ |r| r.opponent ? hash[:opp_score][r.opponent] : 0.0 }.sort 1.upto(rounds - player.results.size) { scores << 0.0 } half = rounds / 2.0 times = rounds >= 9 ? 2 : 1 if method == :harkness || player.points == half 1.upto(times) { scores.shift; scores.pop } else 1.upto(times) { scores.send(player.points > half ? :shift : :pop) } end scores.inject(0.0) { |t,s| t + s } else player.name end end # Detect when an asymmetric result is about to be added, make the appropriate adjustment and return true. # The conditions for an asymric result are: the player's result already exists, the opponent's result # already exists, both results are unrateable and the reverse of one result is equal to the other, apart # from score. In this case all we do update score of the player's result, thus allowing two results whose # total score does not add to 1. def add_asymmetric_result?(result) return false if result.rateable plr = @player[result.player] opp = @player[result.opponent] return false unless plr && opp plr_result = plr.find_result(result.round) opp_result = opp.find_result(result.round) return false unless plr_result && opp_result return false if plr_result.rateable || opp_result.rateable reversed = plr_result.reverse return false unless reversed && reversed.eql?(opp_result, :except => :score) plr_result.score = result.score true end end