module ICU class Tournament # # The SWissPerfect export format used to be important in Irish chess as it was used to submit # results to the ICU's first computerised ratings system, a MicroSoft Access database. # As a text based format, it was easier to manipulate than the full binary formats of SwissPerfect. # Here is an illustrative example of this format: # # No Name Feder Intl Id Loc Id Rtg Loc Title Total 1 2 3 # # 1 Duck, Daffy IRL 12345 2200 im 2 0:= 3:W 2:D # 2 Mouse, Minerva 1234568 1900 1.5 3:D 0:= 1:D # 3 Mouse, Mickey USA 1234567 gm 1 2:D 1:L 0:= # # The format does not record either the name nor the start date of the tournament. # Player colours are also missing. When parsing data in this format it is necessary # to specify name and start date explicitly: # # parser = ICU::Tournament::SPExport.new # tournament = parser.parse_file('sample.txt', :name => 'Mickey Mouse Masters', :start => '2011-02-06') # # tournament.name # => "Mickey Mouse Masters" # tournament.start # => "2011-02-06" # tournament.rounds # => 3 # tournament.player(1).name # => "Duck, Daffy" # tournament.player(2).points # => 1.5 # tournament.player(3).fed # => "USA" # # See ICU::Tournament for further details about the object returned. # # The SwissPerfect application offers a number of choices when exporting a tournament cross table, # one of which is the column separator. The ICU::Tournament::SPExport parser can only handle data # with tab separators but is able to cope with any other configuration choices. For example, if # some of the optional columns are missing or if the data is not formatted with space padding. # # To serialize an ICU::Tournament instance to the format, use the _serialize_ method of # the appropriate parser: # # parser = ICU::Tournament::Krause.new # spexport = parser.serialize(tournament) # # or use the _serialize_ method of the instance with the appropraie format name: # # spexport = tournament.serialize('SPExport') # # In either case the method returns a string representation of the tourament in SwissPerfect export # format with tab separators, space padding and (by default) the local player ID and total score # optional columns: # # No Name Loc Id Total 1 2 3 # # 1 Griffiths, Ryan-Rhys 6897 3 4:W 2:W 3:W # 2 Flynn, Jamie 5226 2 3:W 1:L 4:W # 3 Hulleman, Leon 6409 1 2:L 4:W 1:L # 4 Dunne, Thomas 10914 0 1:L 3:L 2:L # # To change which optional columns are output, use the _columns_ option with an array of the column attribute names. # The optional attribute names, together with their column header names in SwissPerfect, are as follows: # # * _fed_: Feder # * _fide_: Intl Id # * _id_: Loc Id # * _fide_: ting_ (Rtg # * _rating_: Loc # * _title_: Title # * _points_: Total # # So, for example, to omitt the optional columns completely, supply an empty array of column names: # # tournament.serialize('SPExport', :columns => []) # # No Name 1 2 3 # # 1 Griffiths, Ryan-Rhys 4:W 2:W 3:W # 2 Flynn, Jamie 3:W 1:L 4:W # 3 Hulleman, Leon 2:L 4:W 1:L # 4 Dunne, Thomas 1:L 3:L 2:L # # Or supply whatever columns you want, for example: # # tournament.serialize('SPExport', :columns => [:fide_id, :fide_rating]) # # Note that the column order in the serialised string is the same as it is in the SwissPerfect application. # The order of column names in the _columns_ hash has no effect. # # The default, when you leave out the _columns_ option is equivalent to: # # tournament.serialize('SPExport', :columns => [:id, :points]) # # The order of players in the serialized output is always by player number and as a side effect of serialization, # the player numbers will be adjusted to ensure they range from 1 to the total number of players maintaining the # original order. If you would prefer rank-order instead, then you must first renumber the players by rank (the # default renumbering method) before serializing. For example: # # spexport = tournament.renumber(:rank).serialize('SPExport') # # Or equivalently, since renumbering by rank is the default, just: # # spexport = tournament.renumber.serialize('SPExport') # # You may wish set the tie-break rules before ranking: # # tournament.tie_breaks = [:buchholz, :neustadtl] # spexport = tournament.rerank.renumber.serialize('SwissPerfect') # # See ICU::Tournament for more about tie-breaks. # class SPExport attr_reader :error # Parse SwissPerfect export data returning a Tournament on success or raising an exception on error. def parse!(spx, arg={}) @tournament = init_tournament(arg) @lineno = 0 @header = nil @results = Array.new spx = ICU::Util.to_utf8(spx) unless arg[:is_utf8] # Process each line. spx.each_line do |line| @lineno += 1 line.strip! # remove leading and trailing white space next if line == '' # skip blank lines if @header process_player(line) else process_header(line) end end # Now that all players are present, add the results to the tournament. @results.each do |r| lineno, player, data, result = r begin @tournament.add_result(result) rescue => err raise "line #{lineno}, player #{player}, result '#{data}': #{err.message}" end end # Finally, exercise the tournament object's internal validation, reranking if neccessary. @tournament.validate!(:rerank => true) @tournament end # Parse SwissPerfect export text returning a Tournament on success or a nil on failure. # In the case of failure, an error message can be retrived via the error method. def parse(spx, arg={}) begin parse!(spx, arg) rescue => ex @error = ex.message nil end end # Same as parse! except the input is a file name rather than file contents. def parse_file!(file, arg={}) spx = ICU::Util.read_utf8(file) arg[:is_utf8] = true parse!(spx, arg) end # Same as parse except the input is a file name rather than file contents. def parse_file(file, arg={}) begin parse_file!(file, arg) rescue => ex @error = ex.message nil end end # Serialise a tournament to SwissPerfect text export format. def serialize(t, arg={}) t.validate!(:type => self) # Ensure a nice set of player numbers and get the number of rounds. t.renumber(:order) rounds = t.last_round # Optional columns. optional = arg[:columns] if arg.instance_of?(Hash) && arg[:columns].instance_of?(Array) optional = [:id, :points] unless optional # Columns identifiers in SwissPerfect order. columns = Array.new columns.push(:num) columns.push(:name) [:fed, :fide_id, :id, :fide_rating, :rating, :title, :points].each { |x| columns.push(x) if optional.include?(x) } # SwissPerfect headers for each column (other than the rounds, which are treated separately). header = Hash.new columns.each do |col| header[col] = case col when :num then "No" when :name then "Name" when :fed then "Feder" when :fide_id then "Intl Id" when :id then "Loc Id" when :fide_rating then "Rtg" when :rating then "Loc" when :title then "Title" when :points then "Total" end end # Widths and formats for each column. width = Hash.new format = Hash.new columns.each do |col| width[col] = t.players.inject(header[col].length) { |l, p| p.send(col).to_s.length > l ? p.send(col).to_s.length : l } format[col] = "%-#{width[col]}s" end # The header, followed by a blank line. formats = columns.map{ |col| format[col] } (1..rounds).each { |r| formats << "%#{width[:num]}d " % r } sp = formats.join("\t") % columns.map{ |col| header[col] } sp << "\r\n\r\n" # The round formats for players are slightly different to those for the header. formats.pop(rounds) (1..rounds).each{ |r| formats << "%#{2+width[:num]}s" } # Serialize the formats already. formats = formats.join("\t") + "\r\n" # Now add a line for each player. t.players.each { |p| sp << p.to_sp_text(rounds, columns, formats) } # And return the whole lot. sp end # Additional tournament validation rules for this specific type. def validate!(t) # None. end # :enddoc: private def init_tournament(arg) raise "tournament name missing" unless arg[:name] raise "tournament start date missing" unless arg[:start] Tournament.new(arg[:name], arg[:start]) end def process_header(line) raise "header should always start with 'No'" unless line.match(/^No\s/) items = line.split(/\t/).map(&:strip) raise "header requires tab separators" unless items.size > 2 @header = Hash.new @rounds = 1 items.each_with_index do |item, i| key = case item when 'No' then :num when 'Name' then :name when 'Feder' then :fed when 'Intl Id' then :fide_id when 'Loc Id' then :id when 'Rtg' then :fide_rating when 'Loc' then :rating when 'Title' then :title when 'Total' then :points when /^[1-9]\d*$/ round = item.to_i @rounds = round if round > @rounds round else nil end @header[key] = i if key end raise "header is missing 'No'" unless @header[:num] raise "header is missing 'Name'" unless @header[:name] (1..@rounds).each { |r| raise "header is missing round #{r}" unless @header[r] } end def process_player(line) items = line.split(/\t/).map(&:strip) raise "line #{@lineno} has too few items" unless items.size > 2 # Player details. num = items[@header[:num]] name = Name.new(items[@header[:name]]) opt = Hash.new [:fed, :title, :id, :fide_id, :rating, :fide_rating].each do |key| if @header[key] val = items[@header[key]] opt[key] = val unless val.nil? || val == '' end end # Create the player and add it to the tournament. player = Player.new(name.first, name.last, num, opt) player.original_name = name.original @tournament.add_player(player) # Save the results for later processing. points = items[@header[:points]] if @header[:points] points = nil if points == '' points = points.to_f if points total = 0.0; (1..@rounds).each do |r| total+= process_result(r, player.num, items[@header[r]]) end total = points if points && fix_invisible_bonuses(player.num, points - total) raise "declared points total (#{points}) does not agree with total from summed results (#{total})" if points && points != total end def process_result(round, player_num, data) raise "illegal result (#{data})" unless data.match(/^(0|[1-9]\d*)?:([-+=LWD])?$/i) opponent = $1.to_i score = $2 || 'L' options = Hash.new options[:opponent] = opponent unless opponent == 0 options[:rateable] = false unless score && score.match(/^(W|L|D)$/i) result = Result.new(round, player_num, score, options) @results << [@lineno, player_num, data, result] result.points end def fix_invisible_bonuses(player_num, difference) # We don't need to fix it if it's not broken. return false if difference == 0.0 # We can't fix a summed total that is greater than the declared total. return false if difference < 0.0 # Get the player's results objects from the temporary store. results = @results.select{ |r| r[1] == player_num }.map{ |r| r.last } # Get all losses and draws that don't have opponents (because their scores can be harmlessly altered). losses = results.reject{ |r| r.opponent || r.score != 'L' }.sort{ |a,b| a.round <=> b.round } draws = results.reject{ |r| r.opponent || r.score != 'D' }.sort{ |a,b| a.round <=> b.round } # Give up unless these results have enough capacity to accomodate the points difference. return false unless difference <= 1.0 * losses.size + 0.5 * draws.size # Start promoting losses to draws. losses.each do |loss| loss.score = 'D' difference -= 0.5 break if difference == 0.0 end # If that's not enough, start promoting draws to wins. if difference > 0.0 draws.each do |draw| draw.score = 'W' difference -= 0.5 break if difference == 0.0 end end # And if that's not enough, start promoting losses to wins. if difference > 0.0 losses.each do |loss| loss.score = 'W' difference -= 0.5 break if difference == 0.0 end end # Signal success. return true end end end class Player # Format a player's record as it would appear in an SP export file. def to_sp_text(rounds, columns, formats) values = columns.inject([]) do |vals,col| val = send(col).to_s val.sub!(/\.0/, '') if col == :points vals << val end (1..rounds).each do |r| result = find_result(r) values << (result ? result.to_sp_text : " : ") end formats % values end end class Result # Format a player's result as it would appear in an SP export file. def to_sp_text sp = opponent ? opponent.to_s : '0' sp << ':' if rateable sp << score else sp << case score when 'W' then '+' when 'L' then '-' else '=' end end end end end