require 'inifile'
require 'dbf'
require 'zip/zipfilesystem'
require 'tempfile'
module ICU
class Tournament
=begin rdoc
== SwissPerfect
This is the format produced by the Windows program, SwissPerfect[http://www.swissperfect.com/]. It consists of three
files with the same name but different endings: .ini for meta data such as tournament name and tie-break
rules, .trn for the player details such as name and rating, and .sco for the results. The first
file is text and the other two are in an old binary format known as DBase 3.
To parse such a set of files, use either the parse_file! or _parse_file_ method supplying the name of any one
of the three files or just the stem name without any ending. In case of error, such as any of the files not being
found, parse_file! will throw an exception while _parse_file_ will return _nil_ and record an error message.
As well as a file name or stem name, you should also supply a start date in the options as SwissPerfect does not
record this information.
parser = ICU::Tournament::SwissPerfect.new
tournament = parser.parse_file('champs', :start => '2010-07-03') # looks for "champs.ini", "champs.trn" and "champs.sco"
puts tournament ? 'ok' : "problem: #{parser.error}"
Alternatively, if all three files are in a ZIP archive, the parser will extract them if the name of the
archive file is supplied to the _parse_file_ method and it ends in ".zip" (case insensitive):
tournament = parser.parse_file('champs.zip', :start => '2010-07-03')
Or, if the file is a ZIP archive but it's name doesn't end in ".zip", that can be signalled with an option:
tournament = parser.parse_file('/tmp/a84f21ge', :zip => true, :start => '2010-07-03')
Note there must be only three files in the archive, they must all have the same stem name and
their endings should be ".ini", ".trn" and ".sco" (case insensitive).
If no start date is supplied it will default to 2000-01-01, and can be reset later.
tournament = parser.parse_file('champs.zip')
tournament.start # => '2000-01-01'
tournament.start = '2010-07-03'
SwissPerfect files have slots for both local and international IDs and these, if present
and if intergers are copied to the _id_ and _fide_ attributes respectively.
By default, the parser extracts the local rating from the SwissPerfect files and not the international one.
If international ratings are required instead, set the _rating_ option to "intl". For example:
tournament = parser.parse_file('ncc', :start => '2010-05-08')
tournament.player(2).id # => 12379 (ICU ID)
tournament.player(2).fide # => 1205064 (FIDE ID)
tournament.player(2).rating # => 2556 (ICU rating)
tournament = parser.parse_file('ncc', :start => '2010-05-08', :rating => 'intl')
tournament.player(2).rating # => 2530 (FIDE rating)
By default, the parse will fail completely if the ".trn" file contains any invalid federations (see ICU::Federation).
There are two alternative behaviours controlled by setting the _fed_ option:
tournament = parser.parse_file('ncc', :start => '2010-05-08', :fed == 'skip') # => silently skips invalid federations
tournament = parser.parse_file('ncc', :start => '2010-05-08', :fed == 'ignore') # => ignores all federations
Note that federations that don't match 3 letters are always silently skipped.
Because the data is in three parts, some of which are in a legacy binary format, serialization to this format is
not supported. Instead, a method is provided to serialize any tournament type into the text export format of
erfect, an example of which is shown below.
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
This format is important in Irish chess, as it's the format used to submit results to the MicroSoft Access
implementation of the ICU ratings database.
swiss_perfect = tournament.serialize('SwissPerfect')
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 (i.e. renumbered
in 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:
swiss_perfect = tournament.renumber.serialize('SwissPerfect')
There should be no need to explicitly rank the tournament first, as that information is already present in
SwissPerfect files (i.e. each player should already have a rank after the files have been parsed).
Additionally, the tie break rules used for the tournament are available from the _tie_break_ method,
for example:
tournament.tie_breaks # => [:buchholz, :harkness]
Should you wish to rank the tournament using a different set of tie-break rules, you can do something like the following:
tournament.tie_breaks = [:wins, :blacks]
swiss_perfect = tournament.rerank.renumber.serialize('SwissPerfect')
See ICU::Tournament for more about tie-breaks.
=end
class SwissPerfect
attr_reader :error
TRN = {
:dob => "BIRTH_DATE",
:fed => "FEDER",
:first_name => "FIRSTNAME",
:gender => "SEX",
:id => "LOC_ID",
:fide => "INTL_ID",
:last_name => "SURNAME",
:num => "ID",
:rank => "ORDER",
:rating => ["LOC_RTG", "INTL_RTG"],
} # not used: ABSENT BOARD CLUB FORB_PAIRS LATE_ENTRY LOC_RTG2 MEMO TEAM TECH_SCORE WITHDRAWAL (START_NO, BONUS used below)
SCO = %w{ROUND WHITE BLACK W_SCORE B_SCORE W_TYPE B_TYPE} # not used W_SUBSCO, B_SUBSCO
# Parse SP data returning a Tournament or raising an exception on error.
def parse_file!(file, arg={})
@t = Tournament.new('Dummy', '2000-01-01')
@t.start = arg[:start] if arg[:start]
@bonus = {}
@start_no = {}
ini, trn, sco = get_files(file, arg)
parse_ini(ini)
parse_trn(trn, arg)
parse_sco(sco)
fixup
@t.validate!(:rerank => true)
@t
end
# Parse SP data returning an ICU::Tournament or a nil on failure. In the latter
# case, an error message will be available via the error method.
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 numbers.
t.renumber(:order)
# Widths for the rank, name and ID and the number of rounds.
m1 = t.players.inject(2) { |l, p| p.num.to_s.length > l ? p.num.to_s.length : l }
m2 = t.players.inject(4) { |l, p| p.name.length > l ? p.name.length : l }
m3 = t.players.inject(6) { |l, p| p.id.to_s.length > l ? p.id.to_s.length : l }
rounds = t.last_round
# The header, followed by a blank line.
formats = ["%-#{m1}s", "%-#{m2}s", "%-#{m3}s", "%-5s"]
(1..rounds).each { |r| formats << "%#{m1}d " % r }
sp = formats.join("\t") % ['No', 'Name', 'Loc Id', 'Total']
sp << "\r\n\r\n"
# Adjust the round parts of the formats for players results.
(1..t.last_round).each { |r| formats[r+3] = "%#{m1+2}s" }
# Now add a line for each player.
t.players.each { |p| sp << p.to_sp_text(rounds, "#{formats.join(%Q{\t})}\r\n") }
# And return the whole lot.
sp
end
# Additional tournament validation rules for this specific type.
def validate!(t)
# None.
end
private
def get_files(file, arg)
file.match(/\.zip$/i) || arg[:zip] ? get_zip_files(file) : get_bare_files(file)
end
def get_bare_files(file)
file.sub!(/\.\w+$/, '')
%w(ini trn sco).map do |p|
q = [p, p.upcase].detect { |r| File.file? "#{file}.#{r}" }
raise "cannot find file #{file}.#{p}" unless q
"#{file}.#{q}"
end
end
def get_zip_files(file)
temp = Hash.new
begin
Zip::ZipFile.open(file) do |zf|
raise "ZIP file should contain exactly 3 files (.ini, .trn and .sco)" unless zf.size == 3
stem = Hash.new
zf.entries.each do |e|
if e.file? && e.name.match(/^(.+)\.(ini|trn|sco)$/i)
stm = $1
ext = $2.downcase
stem[ext] = stm
tmp = Tempfile.new(e.name)
pth = tmp.path
tmp.close!
e.extract(pth) { true }
temp[ext] = pth
end
end
%w(ini trn sco).each { |ext| raise "no #{ext.upcase} file found" unless stem[ext] }
raise "different stem names found" unless stem['ini'] == stem['trn'] && stem['trn'] == stem['sco']
end
rescue Zip::ZipError
raise "invalid ZIP file"
rescue => ex
raise ex
end
%w(ini trn sco).map { |ext| temp[ext] }
end
def parse_ini(file)
begin
ini = IniFile.load(file)
rescue
raise "invalid INI file"
end
raise "invalid INI file (no sections)" if ini.sections.size == 0
%w(name arbiter rounds).each do |key|
val = (ini['Tournament Info'][key.capitalize] || '').squeeze(" ").strip
@t.send("#{key}=", val) if val.size > 0
end
@t.tie_breaks = ini['Standings']['Tie Breaks'].to_s.split(/,/).map do |tbid|
case tbid.to_i # tie break name in SwissPerfect
when 1217 then :buchholz # Buchholz
when 1218 then :harkness # Median Buchholz
when 1219 then :progressive # cumulative
when 1220 then :neustadtl # Berger
when 1221 then :ratings # rating sum
when 1222 then :wins # number of wins
when 1223 then nil # minor scores - not applicable
when 1226 then nil # Brightwell - not applicable
else nil
end
end.find_all { |tb| tb }
end
def parse_trn(file, arg={})
begin
trn = DBF::Table.new(file)
rescue
raise "invalid TRN file"
end
raise "invalid TRN file (no records)" if trn.record_count == 0
trn.each do |r|
next unless r
h = trn_record_to_hash(r, arg)
@t.add_player(ICU::Player.new(h.delete(:first_name), h.delete(:last_name), h.delete(:num), h))
end
end
def parse_sco(file)
begin
sco = DBF::Table.new(file)
rescue
raise "invalid SCO file"
end
raise "invalid SCO file (no records)" if sco.record_count == 0
sco.each do |r|
next unless r
hs = sco_record_to_hashes(r)
hs.each { |h| @t.add_result(ICU::Result.new(h.delete(:round), h.delete(:player), h.delete(:score), h)) }
end
end
def trn_record_to_hash(r, arg={})
@bonus[r.attributes["ID"]] = %w{BONUS MEMO}.inject(0.0){ |b,k| b > 0.0 ? b : r.attributes[k].to_f }
@start_no[r.attributes["ID"]] = r.attributes["START_NO"]
TRN.inject(Hash.new) do |hash, pair|
key = pair[1]
key = key[arg[pair[0]].to_s == 'intl' ? 1 : 0] if key.class == Array
val = r.attributes[key]
case pair[0]
when :fed then val = val && val.match(/^[A-Z]{3}$/i) ? val.upcase : nil
when :gender then val = val.to_i > 0 ? %w(M F)[val.to_i-1] : nil
when :id then val = val.to_i > 0 ? val : nil
when :fide then val = val.to_i > 0 ? val : nil
when :rating then val = val.to_i > 0 ? val : nil
when :title then val = val.to_i > 0 ? %w(GM WGM IM WIM FM WFM)[val.to_i-1] : nil
end
if pair[0] == :fed && val && arg[:fed]
val = nil if arg[:fed].to_s == 'ignore'
val = nil if arg[:fed].to_s == 'skip' && !ICU::Federation.find(val)
end
hash[pair[0]] = val unless val.nil? || val == ''
hash
end
end
def sco_record_to_hashes(record)
r, w, b, ws, bs, wt, bt = SCO.map { |k| record.attributes[k] }
hashes = []
if w > 0 && b > 0 && ws + bs == 2
hashes.push({ :round => r, :player => w, :score => %w(L D W)[ws], :opponent => b, :colour => 'W' })
hashes.last[:rateable] = false unless wt == 1 && bt == 1
else
hashes.push({ :round => r, :player => w, :score => %w(L D W)[ws], :colour => 'W' }) if w > 0
hashes.push({ :round => r, :player => b, :score => %w(L D W)[bs], :colour => 'B' }) if b > 0
end
hashes
end
def fixup
fix_number_of_rounds
fix_missing_results
fix_bonuses
fix_numbering
end
def fix_number_of_rounds
rounds = @t.last_round
@t.rounds = rounds
end
def fix_missing_results
@t.players.each { |p| @t.add_result(ICU::Result.new(1, p.num, 'L')) if p.results.size == 0 }
end
def fix_bonuses
@t.players.each do |p|
bonus = @bonus[p.num] || 0
next unless bonus > 0
# Try to distribute the bonus in half-points to rounds where the player has no result.
(1..@t.rounds).each do |r|
result = p.find_result(r)
next if result
bonus = bonus - 0.5
p.add_result(ICU::Result.new(r, p.num, 'D'))
break if bonus <= 0
end
next unless bonus > 0
# Try to distribute the bonus in half-points to rounds where the player has unrated results.
(1..@t.rounds).each do |r|
result = p.find_result(r)
next unless result
next if result.opponent
next if result.score == 'W'
bonus = bonus - 0.5
result.score = result.score == 'D' ? 'W' : 'D'
break if bonus <= 0
end
end
end
def fix_numbering
@t.renumber(@start_no)
end
end
end
class Player
# Format a player's record as it would appear in an SP text export file.
def to_sp_text(rounds, format)
attrs = [num.to_s, name, id.to_s, ('%.1f' % points).sub(/\.0/, '')]
(1..rounds).each do |r|
result = find_result(r)
attrs << (result ? result.to_sp_text : " : ")
end
format % attrs
end
end
class Result
# Format a player's result as it would appear in an SP text 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