require 'inifile'
require 'dbf'
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 ID, 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 must also supply a start date because SwissPerfect does not record this
information.
parser = ICU::Tournament::SwissPerfect.new
tournament = parser.parse_file('champs', "2010-07-03") # looks for "champs.ini", "champs.trn" and "champs.sco"
puts parser.error unless tournament
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 text in the format of SwissPerfects
text export format, 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')
As a side effect of serialization, the player numbers will be reordered (to ensure they range from 1 to the total
number of players) and their order in the serialized format will be by player number. If you would like to have
rank order instead, then first rank the tournament (if it isn't already ranked) and then call the _renumber_ method
without the option argument (which will renumber by rank) before serializing. For example:
swiss_perfect = tournament.rerank(:neustadtl, :buchholz).renumber.serialize('SwissPerfect)
== Todo
* Allow parsing from 1 zip file
=end
class SwissPerfect
attr_reader :error
TRN = {
:dob => "BIRTH_DATE",
:fed => "FEDER",
:first_name => "FIRSTNAME",
:gender => "SEX",
:id => ["LOC_ID", "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, start)
@t = Tournament.new('Dummy', start)
@bonus = {}
@start_no = {}
ini, trn, sco = get_files(file)
parse_ini(ini)
parse_trn(trn)
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, start)
begin
parse_file!(file, start)
rescue => ex
@error = ex.message
nil
end
end
# Serialise a tournament to SwissPerfect text export format.
def serialize(t)
return nil unless t.class == ICU::Tournament && t.players.size > 2;
# 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
private
def get_files(file)
file.match(/\.zip$/i) ? get_zipped_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_zipped_files(file)
raise "get_zip_files not implemented"
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 nil # Progress - not implenented yet
when 1220 then :neustadtl # Berger
when 1221 then nil # Rating Sum - not implemented yet
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)
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)
@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)
@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|
keys = pair[1]
keys = [keys] unless keys.class == Array
val, val2 = keys.map { |k| r.attributes[k] }
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 : (val2.to_i > 0 ? val2 : nil)
when :rating then val = val.to_i > 0 ? val : (val2.to_i > 0 ? val2 : nil)
when :title then val = val.to_i > 0 ? %w(GM WGM IM WIM FM WFM)[val.to_i-1] : nil
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