require 'inifile'
require 'dbf'
require 'zip/zipfilesystem'
require 'tempfile'
module ICU
class Tournament
#
# 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.
#
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