module ICU # # 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 'rubygems' # 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. # # See ICU::Player and ICU::Result for more details about players and results. # # == 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 # * every player has a least one result # * the result round numbers are consistent (no more than one game per player per round) # * the tournament dates (start, finish, round dates), if there are any, are consistent # * the player ranks are consistent with their scores # # 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 can be performed given a tournament # parser/serializer. For example: # # t.validate!(:type => ICU::Tournament.ForeignCSV.new) # # Or equivalently: # # t.validate!(:type => 'ForeignCSV') # # Such additional validation is always performed 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 some but not all players have a rank or if all players # have a rank but some are ranked higher than others on lower scores. # # To rank the players requires a tie break method to be specified to order players on the same score. # The default is alphabetical (by last name then first name). Other methods can be specified by supplying # an array of methods (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 = [] # reset to the default # # The full list of supported methods is: # # * _Buchholz_: sum of opponents' scores # * _Harkness_ (or _median_): like Buchholz except the highest and lowest opponents' scores are discarded (or two highest and lowest if 9 rounds or more) # * _modified_median_: same as Harkness except only lowest (or highest) score(s) are discarded for players with more (or less) than 50% # * _Neustadtl_ (or _Sonneborn-Berger_): sum of scores of players defeated plus half sum of scores of players drawn against # * _progressive_ (or _cumulative_): sum of running score for each round # * _ratings_: sum of opponents ratings # * _blacks_: number of blacks # * _wins_: number of wins # * _name_: alphabetical by name (if _tie_breaks_ is set to an empty array, as it is initially, then this will be used as the back-up tie breaker) # # The return value from _rerank_ is the tournament object itself, to allow 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. # class Tournament extend ICU::Accessor attr_date :start attr_date_or_nil :finish attr_positive_or_nil :rounds attr_string %r%[a-z]%i, :name attr_string_or_nil %r%[a-z]%i, :city, :type, :arbiter, :deputy attr_string_or_nil %r%[1-9]%i, :time_control attr_reader :round_dates, :site, :fed, :teams, :tie_breaks # Constructor. Name and start date must be supplied. Other attributes are optional. 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 # Set the tournament federation. Can be _nil_. def fed=(fed) obj = Federation.find(fed) @fed = obj ? obj.code : nil raise "invalid tournament federation (#{fed})" if @fed.nil? && fed.to_s.strip.length > 0 end # Add a round date. def add_round_date(round_date) round_date = round_date.to_s.strip parsed_date = Util.parsedate(round_date) raise "invalid round date (#{round_date})" unless parsed_date @round_dates << parsed_date @round_dates.sort! end # Return the date of a given round, or nil if unavailable. def round_date(round) @round_dates[round-1] end # Return the greatest round number according to the players results (which may not be the same as the set number of rounds). 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 # Set the tournament web site. Should be either unknown (_nil_) or a reasonably valid looking URL. 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 # 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. 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 # Return the team object that matches a given name, or nil if not found. def get_team(name) @teams.find{ |t| t.matches(name) } end # Set the tie break methods. def tie_breaks=(tie_breaks) raise "argument error - always set tie breaks to an array" unless tie_breaks.class == Array # Canonicalise the tie break method names. tie_breaks.map! do |m| m = m.to_s if m.class == Symbol m = m.downcase.gsub(/[-\s]/, '_') if m.class == String case m when true then 'name' when 'sonneborn_berger' then 'neustadtl' when 'modified_median' then 'modified' when 'median' then 'harkness' when 'cumulative' then 'progressive' else m end end # Check they're all valid. tie_breaks.each { |m| raise "invalid tie break method '#{m}'" unless m.to_s.match(/^(blacks|buchholz|harkness|modified|name|neustadtl|progressive|ratings|wins)$/) } # Finally set them. @tie_breaks = tie_breaks; end # Add a new player to the tournament. Must have a unique player number. 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 # Get a player by their number. def player(num) @player[num] end # Return an array of all players in order of their player number. def players @player.values.sort_by{ |p| p.num } end # Lookup a player in the tournament by player number, returning _nil_ if the player number does not exist. def find_player(player) players.find { |p| p == 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, unless the opponent does not exist (e.g. byes, walkovers). By default, if the result is rateable # then the opponent's result will also be rateable. To make the opponent's result unrateable, set the optional # second parameter to false. def add_result(result, reverse_rateable=true) 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] @player[result.player].add_result(result) if result.opponent raise "opponent number (#{result.opponent}) does not exist" unless @player[result.opponent] reverse = result.reverse reverse.rateable = false unless reverse_rateable @player[result.opponent].add_result(reverse) end end # Rerank the tournament by score first and if necessary using a configurable tie breaker method. 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 a hash (player number to value) of tie break scores for the main method. 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 # Renumber the players according to a given criterion. 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 # 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 type.match(/^(SwissPerfect|Krause|ForeignCSV)$/); parser = "ICU::Tournament::#{format}".constantize.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 'swissperfect' then ICU::Tournament::SwissPerfect.new when '' then raise "no format supplied" else raise "unsupported serialisation format: '#{format}'" end serializer.serialize(self, arg) end private # Check players. def check_players raise "the number of players (#{@player.size}) must be at least 2" if @player.size < 2 @player.each do |num, p| raise "player #{num} has no results" if p.results.size == 0 p.results.each do |r| next unless r.opponent raise "opponent #{r.opponent} of player #{num} is not in the tournament" unless @player[r.opponent] 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]}) comes before the start (#{@start}) of the tournament" if @start && @start > @round_dates[0] raise "the date of the last round (#{@round_dates[-1]}) comes after the end (#{@finish}) of the tournament" if @finish && @finish < @round_dates[-1] @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 type.to_s.match(/^(ForeignCSV|Krause|SwissPerfect)$/) parser = "ICU::Tournament::#{type.to_s}".constantize.new.validate!(self) else raise "invalid type supplied for validation check" end end # Return an array of tie break methods 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].rating ? @player[r.opponent].rating : 0) } when 'harkness', 'modified' 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 end end