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:

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:

Side effects of calling validate! or invalid include:

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

fed[R]
round_dates[R]
site[R]
teams[R]
tie_breaks[R]

Public Class Methods

new(name, start, opt={}) click to toggle source

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
parse_file!(file, format, opts={}) click to toggle source

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_player(player) click to toggle source

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_result(result) click to toggle source

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_round_date(round_date) click to toggle source

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_team(team) click to toggle source

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
fed=(fed) click to toggle source

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
find_player(player) click to toggle source

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
get_team(name) click to toggle source

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
guess_round_dates() click to toggle source

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
invalid(options={}) click to toggle source

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
last_round() click to toggle source

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
player(num) click to toggle source

Get a player by their number.

# File lib/icu_tournament/tournament.rb, line 288
def player(num)
  @player[num]
end
players() click to toggle source

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(criterion = :rank) click to toggle source

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() click to toggle source

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
round_date(round) click to toggle source

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
serialize(format, arg={}) click to toggle source

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
site=(site) click to toggle source

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
tie_break_scores() click to toggle source

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
tie_breaks=(tie_breaks) click to toggle source

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
validate!(options={}) click to toggle source

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