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 +1 -1
- data/lib/icu_tournament.rb +1 -1
- data/lib/icu_tournament/federation.rb +6 -0
- data/lib/icu_tournament/player.rb +7 -7
- data/lib/icu_tournament/tournament.rb +11 -10
- data/lib/icu_tournament/tournament_fcsv.rb +14 -12
- data/lib/icu_tournament/tournament_krause.rb +10 -7
- data/lib/icu_tournament/tournament_sp.rb +27 -63
- data/lib/icu_tournament/tournament_spx.rb +397 -0
- data/lib/icu_tournament/version.rb +1 -1
- data/spec/player_spec.rb +50 -36
- data/spec/tournament_fcsv_spec.rb +50 -50
- data/spec/tournament_krause_spec.rb +26 -20
- data/spec/tournament_sp_spec.rb +46 -54
- data/spec/tournament_spx_spec.rb +379 -0
- metadata +7 -4
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
|
|
data/lib/icu_tournament.rb
CHANGED
@@ -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_
|
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', :
|
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
|
-
#
|
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'
|
402
|
-
when 'foreigncsv'
|
403
|
-
when '
|
404
|
-
when ''
|
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
|
-
|
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, :
|
105
|
-
# t.add_player(ICU::Player.new('Peter P.', 'Taylor', 2, :
|
106
|
-
# t.add_player(ICU::Player.new('Egozi', 'Nadav', 3, :
|
107
|
-
# t.add_player(ICU::Player.new('Peter', 'Cafolla', 4, :
|
108
|
-
# t.add_player(ICU::Player.new('Tim R.', 'Spanton', 5, :
|
109
|
-
# t.add_player(ICU::Player.new('Alan', 'Grant', 6, :
|
110
|
-
# t.add_player(ICU::Player.new('Alan J.', 'Walton', 7, :
|
111
|
-
# t.add_player(ICU::Player.new('Bernard', 'Bannink', 8, :
|
112
|
-
# t.add_player(ICU::Player.new('Roy', 'Phillips', 9, :
|
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.
|
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, :
|
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 =
|
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
|
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
|
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
|
49
|
-
# tournament.player(2).fide
|
50
|
-
# tournament.player(2).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
|
-
#
|
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
|
105
|
-
:fed
|
106
|
-
:first_name
|
107
|
-
:gender
|
108
|
-
:id
|
109
|
-
:fide
|
110
|
-
:last_name
|
111
|
-
:num
|
112
|
-
:rank
|
113
|
-
:rating
|
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
|
287
|
-
when :gender
|
288
|
-
when :id
|
289
|
-
when :fide
|
290
|
-
when :rating
|
291
|
-
when :
|
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
|