icu_tournament 1.2.8 → 1.3.1

Sign up to get free protection for your applications and to get access to all the features.
data/README.rdoc CHANGED
@@ -2,7 +2,6 @@
2
2
 
3
3
  For reading or writing files of chess tournament data. Original project name on github was _chess_icu_.
4
4
 
5
-
6
5
  == Install
7
6
 
8
7
  For ruby 1.9.2 (version 1.1.2 was the last compatible with ruby 1.8.7).
@@ -27,6 +26,7 @@ The currently supported formats are:
27
26
  * ICU::Tournament::Krause - the format used by FIDE.
28
27
  * ICU::Tournament::ForeignCSV - used by Irish players to report their individual results in foreign tournaments.
29
28
  * ICU::Tournament::SwissPerfect - often used by Irish tournament controllers to report results.
29
+ * ICU::Tournament::SPExport - the SwissPerfect text export format.
30
30
 
31
31
  == Writing Files
32
32
 
@@ -5,6 +5,6 @@ require 'icu_name'
5
5
  icu_tournament_files = Array.new
6
6
  icu_tournament_files.concat %w{util federation}
7
7
  icu_tournament_files.concat %w{player result team tournament}
8
- icu_tournament_files.concat %w{fcsv krause sp}.map{ |f| "tournament_#{f}"}
8
+ icu_tournament_files.concat %w{fcsv krause sp spx}.map{ |f| "tournament_#{f}"}
9
9
 
10
10
  icu_tournament_files.each { |file| require "icu_tournament/#{file}" }
@@ -81,6 +81,10 @@ module ICU
81
81
  matches[0]
82
82
  end
83
83
 
84
+ # Return an array of codes and names suitable for creating a federation menu in Rails.
85
+ # ICU::Federation.menu(:order => 'code') # order federations by code (instead of by name)
86
+ # ICU::Federation.menu(:top => 'IRL') # make this federation come first
87
+ # ICU::Federation.menu(:none => 'None') # add a dummy top entry with name "None" and blank code
84
88
  def self.menu(opts = {})
85
89
  compile
86
90
  top, menu = nil, []
@@ -91,6 +95,7 @@ module ICU
91
95
  menu
92
96
  end
93
97
 
98
+ # Return an array of sorted federation codes.
94
99
  def self.codes
95
100
  compile
96
101
  @@objects.map(&:code).sort
@@ -101,6 +106,7 @@ module ICU
101
106
  @name = name
102
107
  end
103
108
 
109
+ # :enddoc:
104
110
  private
105
111
 
106
112
  def self.compile
@@ -12,9 +12,10 @@ module ICU
12
12
  #
13
13
  # In addition, players have a number of optional attributes which can be specified
14
14
  # via setters or in constructor hash options: _id_ (local or national ID), _fide_
15
- # (FIDE ID), _fed_ (federation), _title_, _rating_, _rank_ and _dob_ (date of birth).
15
+ # (FIDE ID), _fed_ (federation), _title_, _rating_ (local rating), _fide_rating,
16
+ # _rank_ and _dob_ (date of birth).
16
17
  #
17
- # peter = ICU::Player.new('Peter', 'Svidler', 21, :fed => 'rus', :title => 'g', :rating = 2700)
18
+ # peter = ICU::Player.new('Peter', 'Svidler', 21, :fed => 'rus', :title => 'g', :fide_rating = 2700)
18
19
  # peter.dob = '17th June, 1976'
19
20
  # peter.rank = 1
20
21
  #
@@ -80,11 +81,10 @@ module ICU
80
81
  # fox1.fed # => 'IRL'
81
82
  # fox1.gender # => 'M'
82
83
  #
83
- #
84
84
  class Player
85
85
  extend ICU::Accessor
86
86
  attr_integer :num
87
- attr_positive_or_nil :id, :fide, :rating, :rank
87
+ attr_positive_or_nil :id, :fide, :rating, :fide_rating, :rank
88
88
  attr_date_or_nil :dob
89
89
 
90
90
  attr_reader :results, :first_name, :last_name, :fed, :title, :gender
@@ -94,7 +94,7 @@ module ICU
94
94
  self.first_name = first_name
95
95
  self.last_name = last_name
96
96
  self.num = num
97
- [:id, :fide, :fed, :title, :rating, :rank, :dob, :gender].each do |atr|
97
+ [:id, :fide, :fed, :title, :rating, :fide_rating, :rank, :dob, :gender].each do |atr|
98
98
  self.send("#{atr}=", opt[atr]) unless opt[atr].nil?
99
99
  end
100
100
  @results = []
@@ -192,7 +192,7 @@ module ICU
192
192
  def eql?(other)
193
193
  return true if equal?(other)
194
194
  return false unless self == other
195
- [:id, :fide, :rating, :title, :gender].each do |m|
195
+ [:id, :fide, :rating, :fide_rating, :title, :gender].each do |m|
196
196
  return false if self.send(m) && other.send(m) && self.send(m) != other.send(m)
197
197
  end
198
198
  true
@@ -201,7 +201,7 @@ module ICU
201
201
  # Merge in some of the details of another player.
202
202
  def merge(other)
203
203
  raise "cannot merge two players that are not equal" unless self == other
204
- [:id, :fide, :rating, :title, :fed, :gender].each do |m|
204
+ [:id, :fide, :rating, :fide_rating, :title, :fed, :gender].each do |m|
205
205
  self.send("#{m}=", other.send(m)) unless self.send(m)
206
206
  end
207
207
  end
@@ -95,7 +95,7 @@ module ICU
95
95
  # t.validate(:rerank => true)
96
96
  #
97
97
  # Ranking is inconsistent if some but not all players have a rank or if all players
98
- # have a rank but some are ranked higher than others on lower scores.
98
+ # but at least one pair of players exist where one has a higher score but a lower rank.
99
99
  #
100
100
  # To rank the players requires a tie break method to be specified to order players on the same score.
101
101
  # The default is alphabetical (by last name then first name). Other methods can be specified by supplying
@@ -112,7 +112,7 @@ module ICU
112
112
  # * _modified_median_: same as Harkness except only lowest (or highest) score(s) are discarded for players with more (or less) than 50%
113
113
  # * _Neustadtl_ (or _Sonneborn-Berger_): sum of scores of players defeated plus half sum of scores of players drawn against
114
114
  # * _progressive_ (or _cumulative_): sum of running score for each round
115
- # * _ratings_: sum of opponents ratings
115
+ # * _ratings_: sum of opponents ratings (FIDE ratings are used in preference to local ratings if available)
116
116
  # * _blacks_: number of blacks
117
117
  # * _wins_: number of wins
118
118
  # * _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)
@@ -382,7 +382,7 @@ module ICU
382
382
  # Convenience method to parse a file.
383
383
  def self.parse_file!(file, format, opts={})
384
384
  type = format.to_s
385
- raise "Invalid format" unless type.match(/^(SwissPerfect|Krause|ForeignCSV)$/);
385
+ raise "Invalid format" unless type.match(/^(SwissPerfect|SPExport|Krause|ForeignCSV)$/);
386
386
  parser = "ICU::Tournament::#{format}".constantize.new
387
387
  if type == 'ForeignCSV'
388
388
  # Doesn't take options.
@@ -398,15 +398,16 @@ module ICU
398
398
  # or if the tournament is unsuitable for serialisation in that format.
399
399
  def serialize(format, arg={})
400
400
  serializer = case format.to_s.downcase
401
- when 'krause' then ICU::Tournament::Krause.new
402
- when 'foreigncsv' then ICU::Tournament::ForeignCSV.new
403
- when 'swissperfect' then ICU::Tournament::SwissPerfect.new
404
- when '' then raise "no format supplied"
401
+ when 'krause' then ICU::Tournament::Krause.new
402
+ when 'foreigncsv' then ICU::Tournament::ForeignCSV.new
403
+ when 'spexport' then ICU::Tournament::SPExport.new
404
+ when '' then raise "no format supplied"
405
405
  else raise "unsupported serialisation format: '#{format}'"
406
406
  end
407
407
  serializer.serialize(self, arg)
408
408
  end
409
409
 
410
+ # :enddoc:
410
411
  private
411
412
 
412
413
  # Check players.
@@ -500,8 +501,8 @@ module ICU
500
501
  def check_type(type)
501
502
  if type.respond_to?(:validate!)
502
503
  type.validate!(self)
503
- elsif type.to_s.match(/^(ForeignCSV|Krause|SwissPerfect)$/)
504
- parser = "ICU::Tournament::#{type.to_s}".constantize.new.validate!(self)
504
+ elsif type.to_s.match(/^(ForeignCSV|Krause|SwissPerfect|SPExport)$/)
505
+ "ICU::Tournament::#{type.to_s}".constantize.new.validate!(self)
505
506
  else
506
507
  raise "invalid type supplied for validation check"
507
508
  end
@@ -563,7 +564,7 @@ module ICU
563
564
  when 'neustadtl' then player.results.inject(0.0) { |t,r| t + (r.opponent ? hash['opp-score'][r.opponent] * r.points : 0.0) }
564
565
  when 'opp-score' then player.results.inject(0.0) { |t,r| t + (r.opponent ? r.points : 0.5) } + (rounds - player.results.size) * 0.5
565
566
  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) }
566
- when 'ratings' then player.results.inject(0) { |t,r| t + (r.opponent && @player[r.opponent].rating ? @player[r.opponent].rating : 0) }
567
+ 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) }
567
568
  when 'harkness', 'modified'
568
569
  scores = player.results.map{ |r| r.opponent ? hash['opp-score'][r.opponent] : 0.0 }.sort
569
570
  1.upto(rounds - player.results.size) { scores << 0.0 }
@@ -97,19 +97,20 @@ module ICU
97
97
  # ICU::Tournament::ForeignCSV.new.serialize(tournament)
98
98
  #
99
99
  # You can also build the tournament object from scratch using your own data and then serialize it.
100
- # For example, here are the commands to reproduce the example above.
100
+ # For example, here are the commands to reproduce the example above. Note that in this format
101
+ # opponents' ratings are FIDE while players' IDs are ICU.
101
102
  #
102
103
  # t = ICU::Tournament.new("Isle of Man Masters, 2007", '2007-09-22', :rounds => 9)
103
104
  # t.site = 'http://www.bcmchess.co.uk/monarch2007/'
104
- # t.add_player(ICU::Player.new('Anthony', 'Fox', 1, :rating => 2100, :fed => 'IRL', :id => 456))
105
- # t.add_player(ICU::Player.new('Peter P.', 'Taylor', 2, :rating => 2209, :fed => 'ENG'))
106
- # t.add_player(ICU::Player.new('Egozi', 'Nadav', 3, :rating => 2205, :fed => 'ISR'))
107
- # t.add_player(ICU::Player.new('Peter', 'Cafolla', 4, :rating => 2048, :fed => 'IRL'))
108
- # t.add_player(ICU::Player.new('Tim R.', 'Spanton', 5, :rating => 1982, :fed => 'ENG'))
109
- # t.add_player(ICU::Player.new('Alan', 'Grant', 6, :rating => 2223, :fed => 'SCO'))
110
- # t.add_player(ICU::Player.new('Alan J.', 'Walton', 7, :rating => 2223, :fed => 'ENG'))
111
- # t.add_player(ICU::Player.new('Bernard', 'Bannink', 8, :rating => 2271, :fed => 'NED', :title => 'FM'))
112
- # t.add_player(ICU::Player.new('Roy', 'Phillips', 9, :rating => 2271, :fed => 'MAU'))
105
+ # t.add_player(ICU::Player.new('Anthony', 'Fox', 1, :fide_rating => 2100, :fed => 'IRL', :id => 456))
106
+ # t.add_player(ICU::Player.new('Peter P.', 'Taylor', 2, :fide_rating => 2209, :fed => 'ENG'))
107
+ # t.add_player(ICU::Player.new('Egozi', 'Nadav', 3, :fide_rating => 2205, :fed => 'ISR'))
108
+ # t.add_player(ICU::Player.new('Peter', 'Cafolla', 4, :fide_rating => 2048, :fed => 'IRL'))
109
+ # t.add_player(ICU::Player.new('Tim R.', 'Spanton', 5, :fide_rating => 1982, :fed => 'ENG'))
110
+ # t.add_player(ICU::Player.new('Alan', 'Grant', 6, :fide_rating => 2223, :fed => 'SCO'))
111
+ # t.add_player(ICU::Player.new('Alan J.', 'Walton', 7, :fide_rating => 2223, :fed => 'ENG'))
112
+ # t.add_player(ICU::Player.new('Bernard', 'Bannink', 8, :fide_rating => 2271, :fed => 'NED', :title => 'FM'))
113
+ # t.add_player(ICU::Player.new('Roy', 'Phillips', 9, :fide_rating => 2271, :fed => 'MAU'))
113
114
  # t.add_result(ICU::Result.new(1, 1, 'L', :opponent => 2, :colour => 'B'))
114
115
  # t.add_result(ICU::Result.new(2, 1, 'D', :opponent => 3, :colour => 'W'))
115
116
  # t.add_result(ICU::Result.new(3, 1, 'D', :opponent => 4, :colour => 'B'))
@@ -221,7 +222,7 @@ module ICU
221
222
  o = t.player(r.opponent)
222
223
  data << o.last_name
223
224
  data << o.first_name
224
- data << o.rating
225
+ data << o.fide_rating
225
226
  data << o.title
226
227
  data << o.fed
227
228
  else
@@ -250,6 +251,7 @@ module ICU
250
251
  end
251
252
  end
252
253
 
254
+ # :enddoc:
253
255
  private
254
256
 
255
257
  def event
@@ -308,7 +310,7 @@ module ICU
308
310
  @tournament.add_result(result)
309
311
  else
310
312
  result.colour = @r[2]
311
- opponent = Player.new(@r[4], @r[3], @tournament.players.size + 1, :rating => @r[5], :title => @r[6], :fed => @r[7])
313
+ opponent = Player.new(@r[4], @r[3], @tournament.players.size + 1, :fide_rating => @r[5], :title => @r[6], :fed => @r[7])
312
314
  raise "opponent must have a federation" unless opponent.fed
313
315
  old_player = @tournament.find_player(opponent)
314
316
  if old_player
@@ -48,13 +48,15 @@ module ICU
48
48
  # daffy.fide # => nil
49
49
  # daffy.dob # => "1937-04-17"
50
50
  #
51
- # By default, ID numbers in the input are interpreted as local IDs. If, instead, they should be interpreted as
52
- # FIDE IDs, add the following option:
51
+ # By default, ID numbers and ratings in the input are interpreted as local IDs and ratings. If, instead, they should be interpreted as
52
+ # FIDE IDs and ratings, add the following option:
53
53
  #
54
54
  # tournament = parser.parse_file('tournament.tab', :fide => true)
55
55
  # daffy = tournament.player(2)
56
56
  # daffy.id # => nil
57
57
  # daffy.fide # => 7654321
58
+ # daffy.rating # => nil
59
+ # daffy.fide_rating # => 2200
58
60
  #
59
61
  # If the ranking numbers are missing from the file or inconsistent (e.g. player A is ranked above player B
60
62
  # but has less points than player B) they are recalculated as a side effect of the parse.
@@ -82,11 +84,11 @@ module ICU
82
84
  #
83
85
  # krause = tournament.serialize('Krause')
84
86
  #
85
- # By default, local (ICU) IDs are used for the serialization, but both methods accept an option that
86
- # causes FIDE IDs to be used instead:
87
+ # By default, local (ICU) IDs and ratings are used for the serialization, but both methods accept an option that
88
+ # causes FIDE IDs and ratings to be used instead:
87
89
  #
88
90
  # krause = parser.serialize(tournament, :fide => true)
89
- # krause = parser.serialize(tournament, :fide => true)
91
+ # krause = tournament.serialize('Krause', :fide => true)
90
92
  #
91
93
  # The following lists Krause data identification numbers, their description and, where available, their corresponding
92
94
  # attributes in an ICU::Tournament instance.
@@ -241,6 +243,7 @@ module ICU
241
243
  # None.
242
244
  end
243
245
 
246
+ # :enddoc:
244
247
  private
245
248
 
246
249
  def set_name
@@ -263,12 +266,12 @@ module ICU
263
266
  {
264
267
  :gender => @data[5, 1],
265
268
  :title => @data[6, 3],
266
- :rating => @data[44, 4],
267
269
  :fed => @data[49, 3],
268
270
  :dob => @data[65, 10],
269
271
  :rank => @data[81, 4],
270
272
  }
271
273
  opt[arg[:fide] ? :fide : :id] = @data[53, 11]
274
+ opt[arg[:fide] ? :fide_rating : :rating] = @data[44, 4]
272
275
  player = Player.new(nam.first, nam.last, num, opt)
273
276
  @tournament.add_player(player)
274
277
 
@@ -337,7 +340,7 @@ module ICU
337
340
  krause << sprintf(' %1s', case @gender; when 'M' then 'm'; when 'F' then 'w'; else ''; end)
338
341
  krause << sprintf(' %2s', case @title; when nil then ''; when 'IM' then 'm'; when 'WIM' then 'wm'; else @title[0, @title.length-1].downcase; end)
339
342
  krause << sprintf(' %-33s', "#{@last_name},#{@first_name}")
340
- krause << sprintf(' %4s', @rating)
343
+ krause << sprintf(' %4s', arg[:fide] ? @fide_rating : @rating)
341
344
  krause << sprintf(' %3s', @fed)
342
345
  krause << sprintf(' %11s', arg[:fide] ? @fide : @id)
343
346
  krause << sprintf(' %10s', @dob)
@@ -35,22 +35,17 @@ module ICU
35
35
  # If no start date is supplied it will default to 2000-01-01, and can be reset later.
36
36
  #
37
37
  # tournament = parser.parse_file('champs.zip')
38
- # tournament.start # => '2000-01-01'
38
+ # tournament.start # => '2000-01-01'
39
39
  # tournament.start = '2010-07-03'
40
40
  #
41
- # SwissPerfect files have slots for both local and international IDs and these, if present
42
- # and if intergers are copied to the _id_ and _fide_ attributes respectively.
43
- #
44
- # By default, the parser extracts the local rating from the SwissPerfect files and not the international one.
45
- # If international ratings are required instead, set the _rating_ option to "intl". For example:
41
+ # SwissPerfect files have slots for both local and international IDs and ratings and these, if present
42
+ # (and if integers) are copied to the _id_, _fide_, _rating_ and _fide_rating_ attributes.
46
43
  #
47
44
  # tournament = parser.parse_file('ncc', :start => '2010-05-08')
48
- # tournament.player(2).id # => 12379 (ICU ID)
49
- # tournament.player(2).fide # => 1205064 (FIDE ID)
50
- # tournament.player(2).rating # => 2556 (ICU rating)
51
- #
52
- # tournament = parser.parse_file('ncc', :start => '2010-05-08', :rating => 'intl')
53
- # tournament.player(2).rating # => 2530 (FIDE rating)
45
+ # tournament.player(2).id # => 12379 (ICU ID)
46
+ # tournament.player(2).fide # => 1205064 (FIDE ID)
47
+ # tournament.player(2).rating # => 2556 (ICU rating)
48
+ # tournament.player(2).fide_rating # => 2530 (FIDE rating)
54
49
  #
55
50
  # By default, the parse will fail completely if the ".trn" file contains any invalid federations (see ICU::Federation).
56
51
  # There are two alternative behaviours controlled by setting the _fed_ option:
@@ -62,55 +57,23 @@ module ICU
62
57
  #
63
58
  # Because the data is in three parts, some of which are in a legacy binary format, serialization to this format is
64
59
  # not supported. Instead, a method is provided to serialize any tournament type into the text export format of
65
- # erfect, an example of which is shown below.
66
- #
67
- # No Name Loc Id Total 1 2 3
68
- #
69
- # 1 Griffiths, Ryan-Rhys 6897 3 4:W 2:W 3:W
70
- # 2 Flynn, Jamie 5226 2 3:W 1:L 4:W
71
- # 3 Hulleman, Leon 6409 1 2:L 4:W 1:L
72
- # 4 Dunne, Thomas 10914 0 1:L 3:L 2:L
73
- #
74
- # This format is important in Irish chess, as it's the format used to submit results to the <em>MicroSoft Access</em>
75
- # implementation of the ICU ratings database.
76
- #
77
- # swiss_perfect = tournament.serialize('SwissPerfect')
78
- #
79
- # The order of players in the serialized output is always by player number and as a side effect of serialization,
80
- # the player numbers will be adjusted to ensure they range from 1 to the total number of players (i.e. renumbered
81
- # in order). If you would prefer rank-order instead, then you must first renumber the players by rank (the default
82
- # renumbering method) before serializing. For example:
83
- #
84
- # swiss_perfect = tournament.renumber.serialize('SwissPerfect')
85
- #
86
- # There should be no need to explicitly rank the tournament first, as that information is already present in
87
- # SwissPerfect files (i.e. each player should already have a rank after the files have been parsed).
88
- # Additionally, the tie break rules used for the tournament are available from the _tie_break_ method,
89
- # for example:
90
- #
91
- # tournament.tie_breaks # => [:buchholz, :harkness]
92
- #
93
- # Should you wish to rank the tournament using a different set of tie-break rules, you can do something like the following:
94
- #
95
- # tournament.tie_breaks = [:wins, :blacks]
96
- # swiss_perfect = tournament.rerank.renumber.serialize('SwissPerfect')
97
- #
98
- # See ICU::Tournament for more about tie-breaks.
60
+ # SwissPerfect (see ICU::Tournament::SPExport).
99
61
  #
100
62
  class SwissPerfect
101
63
  attr_reader :error
102
64
 
103
65
  TRN = {
104
- :dob => "BIRTH_DATE",
105
- :fed => "FEDER",
106
- :first_name => "FIRSTNAME",
107
- :gender => "SEX",
108
- :id => "LOC_ID",
109
- :fide => "INTL_ID",
110
- :last_name => "SURNAME",
111
- :num => "ID",
112
- :rank => "ORDER",
113
- :rating => ["LOC_RTG", "INTL_RTG"],
66
+ :dob => "BIRTH_DATE",
67
+ :fed => "FEDER",
68
+ :first_name => "FIRSTNAME",
69
+ :gender => "SEX",
70
+ :id => "LOC_ID",
71
+ :fide => "INTL_ID",
72
+ :last_name => "SURNAME",
73
+ :num => "ID",
74
+ :rank => "ORDER",
75
+ :rating => "LOC_RTG",
76
+ :fide_rating => "INTL_RTG",
114
77
  } # not used: ABSENT BOARD CLUB FORB_PAIRS LATE_ENTRY LOC_RTG2 MEMO TEAM TECH_SCORE WITHDRAWAL (START_NO, BONUS used below)
115
78
 
116
79
  SCO = %w{ROUND WHITE BLACK W_SCORE B_SCORE W_TYPE B_TYPE} # not used W_SUBSCO, B_SUBSCO
@@ -175,6 +138,7 @@ module ICU
175
138
  # None.
176
139
  end
177
140
 
141
+ # :enddoc:
178
142
  private
179
143
 
180
144
  def get_files(file, arg)
@@ -280,15 +244,15 @@ module ICU
280
244
  @start_no[r.attributes["ID"]] = r.attributes["START_NO"]
281
245
  TRN.inject(Hash.new) do |hash, pair|
282
246
  key = pair[1]
283
- key = key[arg[pair[0]].to_s == 'intl' ? 1 : 0] if key.class == Array
284
247
  val = r.attributes[key]
285
248
  case pair[0]
286
- when :fed then val = val && val.match(/^[A-Z]{3}$/i) ? val.upcase : nil
287
- when :gender then val = val.to_i > 0 ? %w(M F)[val.to_i-1] : nil
288
- when :id then val = val.to_i > 0 ? val : nil
289
- when :fide then val = val.to_i > 0 ? val : nil
290
- when :rating then val = val.to_i > 0 ? val : nil
291
- when :title then val = val.to_i > 0 ? %w(GM WGM IM WIM FM WFM)[val.to_i-1] : nil
249
+ when :fed then val = val && val.match(/^[A-Z]{3}$/i) ? val.upcase : nil
250
+ when :gender then val = val.to_i > 0 ? %w(M F)[val.to_i-1] : nil
251
+ when :id then val = val.to_i > 0 ? val : nil
252
+ when :fide then val = val.to_i > 0 ? val : nil
253
+ when :rating then val = val.to_i > 0 ? val : nil
254
+ when :fide_rating then val = val.to_i > 0 ? val : nil
255
+ when :title then val = val.to_i > 0 ? %w(GM WGM IM WIM FM WFM)[val.to_i-1] : nil
292
256
  end
293
257
  if pair[0] == :fed && val && arg[:fed]
294
258
  val = nil if arg[:fed].to_s == 'ignore'
@@ -0,0 +1,397 @@
1
+ module ICU
2
+ class Tournament
3
+ #
4
+ # The SWissPerfect export format used to be important in Irish chess as it was used to submit
5
+ # results to the ICU's first computerised ratings system, a <em>MicroSoft Access</em> database.
6
+ # As a text based format, it was easier to manipulate than the full binary formats of SwissPerfect.
7
+ # Here is an illustrative example of this format:
8
+ #
9
+ # No Name Feder Intl Id Loc Id Rtg Loc Title Total 1 2 3
10
+ #
11
+ # 1 Duck, Daffy IRL 12345 2200 im 2 0:= 3:W 2:D
12
+ # 2 Mouse, Minerva 1234568 1900 1.5 3:D 0:= 1:D
13
+ # 3 Mouse, Mickey USA 1234567 gm 1 2:D 1:L 0:=
14
+ #
15
+ # The format does not record either the name nor the start date of the tournament.
16
+ # Player colours are also missing. When parsing data in this format it is necessary
17
+ # to specify name and start date explicitly:
18
+ #
19
+ # parser = ICU::Tournament::SPExport.new
20
+ # tournament = parser.parse_file('sample.txt', :name => 'Mickey Mouse Masters', :start => '2011-02-06')
21
+ #
22
+ # tournament.name # => "Mickey Mouse Masters"
23
+ # tournament.start # => "2011-02-06"
24
+ # tournament.rounds # => 3
25
+ # tournament.player(1).name # => "Duck, Daffy"
26
+ # tournament.player(2).points # => 1.5
27
+ # tournament.player(3).fed # => "USA"
28
+ #
29
+ # See ICU::Tournament for further details about the object returned.
30
+ #
31
+ # The SwissPerfect application offers a number of choices when exporting a tournament cross table,
32
+ # one of which is the column separator. The ICU::Tournament::SPExport parser can only handle data
33
+ # with tab separators but is able to cope with any other configuration choices. For example, if
34
+ # some of the optional columns are missing or if the data is not formatted with space padding.
35
+ #
36
+ # To serialize an ICU::Tournament instance to the format, use the _serialize_ method of
37
+ # the appropriate parser:
38
+ #
39
+ # parser = ICU::Tournament::Krause.new
40
+ # spexport = parser.serialize(tournament)
41
+ #
42
+ # or use the _serialize_ method of the instance with the appropraie format name:
43
+ #
44
+ # spexport = tournament.serialize('SPExport')
45
+ #
46
+ # In either case the method returns a string representation of the tourament in SwissPerfect export
47
+ # format with tab separators, space padding and (by default) the local player ID and total score
48
+ # optional columns:
49
+ #
50
+ # No Name Loc Id Total 1 2 3
51
+ #
52
+ # 1 Griffiths, Ryan-Rhys 6897 3 4:W 2:W 3:W
53
+ # 2 Flynn, Jamie 5226 2 3:W 1:L 4:W
54
+ # 3 Hulleman, Leon 6409 1 2:L 4:W 1:L
55
+ # 4 Dunne, Thomas 10914 0 1:L 3:L 2:L
56
+ #
57
+ # To change which optional columns are output, use the _columns_ option with an array of the column attribute names.
58
+ # The optional attribute names, together with their column header names in SwissPerfect, are as follows:
59
+ #
60
+ # * _fed_: Feder
61
+ # * _fide_: Intl Id
62
+ # * _id_: Loc Id
63
+ # * _fide_: ting_ (Rtg
64
+ # * _rating_: Loc
65
+ # * _title_: Title
66
+ # * _points_: Total
67
+ #
68
+ # So, for example, to omitt the optional columns completely, supply an empty array of column names:
69
+ #
70
+ # tournament.serialize('SPExport', :columns => [])
71
+ #
72
+ # No Name 1 2 3
73
+ #
74
+ # 1 Griffiths, Ryan-Rhys 4:W 2:W 3:W
75
+ # 2 Flynn, Jamie 3:W 1:L 4:W
76
+ # 3 Hulleman, Leon 2:L 4:W 1:L
77
+ # 4 Dunne, Thomas 1:L 3:L 2:L
78
+ #
79
+ # Or supply whatever columns you want, for example:
80
+ #
81
+ # tournament.serialize('SPExport', :columns => [:fide, :fide_rating])
82
+ #
83
+ # Note that the column order in the serialised string is the same as it is in the SwissPerfect application.
84
+ # The order of column names in the _columns_ hash has no effect.
85
+ #
86
+ # The default, when you leave out the _columns_ option is equivalent to:
87
+ #
88
+ # tournament.serialize('SPExport', :columns => [:id, :points])
89
+ #
90
+ # The order of players in the serialized output is always by player number and as a side effect of serialization,
91
+ # the player numbers will be adjusted to ensure they range from 1 to the total number of players maintaining the
92
+ # original order. If you would prefer rank-order instead, then you must first renumber the players by rank (the
93
+ # default renumbering method) before serializing. For example:
94
+ #
95
+ # spexport = tournament.renumber(:rank).serialize('SPExport')
96
+ #
97
+ # Or equivalently, since renumbering by rank is the default, just:
98
+ #
99
+ # spexport = tournament.renumber.serialize('SPExport')
100
+ #
101
+ # You may wish set the tie-break rules before ranking:
102
+ #
103
+ # tournament.tie_breaks = [:buchholz, ::neustadtl]
104
+ # spexport = tournament.rerank.renumber.serialize('SwissPerfect')
105
+ #
106
+ # See ICU::Tournament for more about tie-breaks.
107
+ #
108
+ class SPExport
109
+ attr_reader :error
110
+
111
+ # Parse SwissPerfect export data returning a Tournament on success or raising an exception on error.
112
+ def parse!(spx, arg={})
113
+ @tournament = init_tournament(arg)
114
+ @lineno = 0
115
+ @header = nil
116
+ @results = Array.new
117
+ spx = ICU::Util.to_utf8(spx) unless arg[:is_utf8]
118
+
119
+ # Process each line.
120
+ spx.each_line do |line|
121
+ @lineno += 1
122
+ line.strip! # remove leading and trailing white space
123
+ next if line == '' # skip blank lines
124
+
125
+ if @header
126
+ process_player(line)
127
+ else
128
+ process_header(line)
129
+ end
130
+ end
131
+
132
+ # Now that all players are present, add the results to the tournament.
133
+ @results.each do |r|
134
+ lineno, player, data, result = r
135
+ begin
136
+ @tournament.add_result(result)
137
+ rescue => err
138
+ raise "line #{lineno}, player #{player}, result '#{data}': #{err.message}"
139
+ end
140
+ end
141
+
142
+ # Finally, exercise the tournament object's internal validation, reranking if neccessary.
143
+ @tournament.validate!(:rerank => true)
144
+
145
+ @tournament
146
+ end
147
+
148
+ # Parse SwissPerfect export text returning a Tournament on success or a nil on failure.
149
+ # In the case of failure, an error message can be retrived via the <em>error</em> method.
150
+ def parse(spx, arg={})
151
+ begin
152
+ parse!(spx, arg)
153
+ rescue => ex
154
+ @error = ex.message
155
+ nil
156
+ end
157
+ end
158
+
159
+ # Same as <em>parse!</em> except the input is a file name rather than file contents.
160
+ def parse_file!(file, arg={})
161
+ spx = ICU::Util.read_utf8(file)
162
+ arg[:is_utf8] = true
163
+ parse!(spx, arg)
164
+ end
165
+
166
+ # Same as <em>parse</em> except the input is a file name rather than file contents.
167
+ def parse_file(file, arg={})
168
+ begin
169
+ parse_file!(file, arg)
170
+ rescue => ex
171
+ @error = ex.message
172
+ nil
173
+ end
174
+ end
175
+
176
+ # Serialise a tournament to SwissPerfect text export format.
177
+ def serialize(t, arg={})
178
+ t.validate!(:type => self)
179
+
180
+ # Ensure a nice set of player numbers and get the number of rounds.
181
+ t.renumber(:order)
182
+ rounds = t.last_round
183
+
184
+ # Optional columns.
185
+ optional = arg[:columns] if arg.instance_of?(Hash) && arg[:columns].instance_of?(Array)
186
+ optional = [:id, :points] unless optional
187
+
188
+ # Columns identifiers in SwissPerfect order.
189
+ columns = Array.new
190
+ columns.push(:num)
191
+ columns.push(:name)
192
+ [:fed, :fide, :id, :fide_rating, :rating, :title, :points].each { |x| columns.push(x) if optional.include?(x) }
193
+
194
+ # SwissPerfect headers for each column (other than the rounds, which are treated separately).
195
+ header = Hash.new
196
+ columns.each do |col|
197
+ header[col] = case col
198
+ when :num then "No"
199
+ when :name then "Name"
200
+ when :fed then "Feder"
201
+ when :fide then "Intl Id"
202
+ when :id then "Loc Id"
203
+ when :fide_rating then "Rtg"
204
+ when :rating then "Loc"
205
+ when :title then "Title"
206
+ when :points then "Total"
207
+ end
208
+ end
209
+
210
+ # Widths and formats for each column.
211
+ width = Hash.new
212
+ format = Hash.new
213
+ columns.each do |col|
214
+ width[col] = t.players.inject(header[col].length) { |l, p| p.send(col).to_s.length > l ? p.send(col).to_s.length : l }
215
+ format[col] = "%-#{width[col]}s"
216
+ end
217
+
218
+ # The header, followed by a blank line.
219
+ formats = columns.map{ |col| format[col] }
220
+ (1..rounds).each { |r| formats << "%#{width[:num]}d " % r }
221
+ sp = formats.join("\t") % columns.map{ |col| header[col] }
222
+ sp << "\r\n\r\n"
223
+
224
+ # The round formats for players are slightly different to those for the header.
225
+ formats.pop(rounds)
226
+ (1..rounds).each{ |r| formats << "%#{2+width[:num]}s" }
227
+
228
+ # Serialize the formats already.
229
+ formats = formats.join("\t") + "\r\n"
230
+
231
+ # Now add a line for each player.
232
+ t.players.each { |p| sp << p.to_sp_text(rounds, columns, formats) }
233
+
234
+ # And return the whole lot.
235
+ sp
236
+ end
237
+
238
+ # Additional tournament validation rules for this specific type.
239
+ def validate!(t)
240
+ # None.
241
+ end
242
+
243
+ # :enddoc:
244
+ private
245
+
246
+ def init_tournament(arg)
247
+ raise "tournament name missing" unless arg[:name]
248
+ raise "tournament start date missing" unless arg[:start]
249
+ Tournament.new(arg[:name], arg[:start])
250
+ end
251
+
252
+ def process_header(line)
253
+ raise "header should always start with 'No'" unless line.match(/^No\s/)
254
+ items = line.split(/\t/).map(&:strip)
255
+ raise "header requires tab separators" unless items.size > 2
256
+ @header = Hash.new
257
+ @rounds = 1
258
+ items.each_with_index do |item, i|
259
+ key = case item
260
+ when 'No' then :num
261
+ when 'Name' then :name
262
+ when 'Total' then :total
263
+ when 'Loc Id' then :id
264
+ when 'Intl Id' then :fide
265
+ when 'Title' then :title
266
+ when 'Feder' then :fed
267
+ when 'Loc' then :rating
268
+ when 'Rtg' then :fide_rating
269
+ when /^[1-9]\d*$/
270
+ round = item.to_i
271
+ @rounds = round if round > @rounds
272
+ round
273
+ else nil
274
+ end
275
+ @header[key] = i if key
276
+ end
277
+ raise "header is missing 'No'" unless @header[:num]
278
+ raise "header is missing 'Name'" unless @header[:name]
279
+ (1..@rounds).each { |r| raise "header is missing round #{r}" unless @header[r] }
280
+ end
281
+
282
+ def process_player(line)
283
+ items = line.split(/\t/).map(&:strip)
284
+ raise "line #{@lineno} has too few items" unless items.size > 2
285
+
286
+ # Player details.
287
+ num = items[@header[:num]]
288
+ name = Name.new(items[@header[:name]])
289
+ opt = Hash.new
290
+ [:fed, :title, :id, :fide, :rating, :fide_rating].each do |key|
291
+ if @header[key]
292
+ val = items[@header[key]]
293
+ opt[key] = val unless val.nil? || val == ''
294
+ end
295
+ end
296
+
297
+ # Create the player and add it to the tournament.
298
+ player = Player.new(name.first, name.last, num, opt)
299
+ @tournament.add_player(player)
300
+
301
+ # Save the results for later processing.
302
+ points = items[@header[:total]] if @header[:total]
303
+ points = nil if points == ''
304
+ points = points.to_f if points
305
+ total = 0.0;
306
+ (1..@rounds).each do |r|
307
+ total+= process_result(r, player.num, items[@header[r]])
308
+ end
309
+ total = points if points && fix_invisible_bonuses(player.num, points - total)
310
+ raise "declared points total (#{points}) does not agree with total from summed results (#{total})" if points && points != total
311
+ end
312
+
313
+ def process_result(round, player_num, data)
314
+ raise "illegal result (#{data})" unless data.match(/^(0|[1-9]\d*)?:([-+=LWD])?$/i)
315
+ opponent = $1.to_i
316
+ score = $2 || 'L'
317
+ options = Hash.new
318
+ options[:opponent] = opponent unless opponent == 0
319
+ options[:rateable] = false unless score && score.match(/^(W|L|D)$/i)
320
+ result = Result.new(round, player_num, score, options)
321
+ @results << [@lineno, player_num, data, result]
322
+ result.points
323
+ end
324
+
325
+ def fix_invisible_bonuses(player_num, difference)
326
+ # We don't need to fix it if it's not broken.
327
+ return false if difference == 0.0
328
+ # We can't fix a summed total that is greater than the declared total.
329
+ return false if difference < 0.0
330
+ # Get the player's results objects from the temporary store.
331
+ results = @results.select{ |r| r[1] == player_num }.map{ |r| r.last }
332
+ # Get all losses and draws that don't have opponents (because their scores can be harmlessly altered).
333
+ losses = results.reject{ |r| r.opponent || r.score != 'L' }.sort{ |a,b| a.round <=> b.round }
334
+ draws = results.reject{ |r| r.opponent || r.score != 'D' }.sort{ |a,b| a.round <=> b.round }
335
+ # Give up unless these results have enough capacity to accomodate the points difference.
336
+ return false unless difference <= 1.0 * losses.size + 0.5 * draws.size
337
+ # Start promoting losses to draws.
338
+ losses.each do |loss|
339
+ loss.score = 'D'
340
+ difference -= 0.5
341
+ break if difference == 0.0
342
+ end
343
+ # If that's not enough, start promoting draws to wins.
344
+ if difference > 0.0
345
+ draws.each do |draw|
346
+ draw.score = 'W'
347
+ difference -= 0.5
348
+ break if difference == 0.0
349
+ end
350
+ end
351
+ # And if that's not enough, start promoting losses to wins.
352
+ if difference > 0.0
353
+ losses.each do |loss|
354
+ loss.score = 'W'
355
+ difference -= 0.5
356
+ break if difference == 0.0
357
+ end
358
+ end
359
+ # Signal success.
360
+ return true
361
+ end
362
+ end
363
+ end
364
+
365
+ class Player
366
+ # Format a player's record as it would appear in an SP export file.
367
+ def to_sp_text(rounds, columns, formats)
368
+ values = columns.inject([]) do |vals,col|
369
+ val = send(col).to_s
370
+ val.sub!(/\.0/, '') if col == :points
371
+ vals << val
372
+ end
373
+ (1..rounds).each do |r|
374
+ result = find_result(r)
375
+ values << (result ? result.to_sp_text : " : ")
376
+ end
377
+ formats % values
378
+ end
379
+ end
380
+
381
+ class Result
382
+ # Format a player's result as it would appear in an SP export file.
383
+ def to_sp_text
384
+ sp = opponent ? opponent.to_s : '0'
385
+ sp << ':'
386
+ if rateable
387
+ sp << score
388
+ else
389
+ sp << case score
390
+ when 'W' then '+'
391
+ when 'L' then '-'
392
+ else '='
393
+ end
394
+ end
395
+ end
396
+ end
397
+ end