require 'fastercsv'
module ICU
class Tournament
=begin rdoc
== Foreign CSV
This is a format ({specification}[http://www.icu.ie/articles/display.php?id=172]) used by the ICU[http://icu.ie]
for players to submit their individual results in foreign tournaments for domestic rating.
Suppose, for example, that the following data is the file tournament.csv:
Event,"Isle of Man Masters, 2007"
Start,2007-09-22
Rounds,9
Website,http://www.bcmchess.co.uk/monarch2007/
Player,456,Fox,Anthony
1,0,B,Taylor,Peter P.,2209,,ENG
2,=,W,Nadav,Egozi,2205,,ISR
3,=,B,Cafolla,Peter,2048,,IRL
4,1,W,Spanton,Tim R.,1982,,ENG
5,1,B,Grant,Alan,2223,,SCO
6,0,-
7,=,W,Walton,Alan J.,2223,,ENG
8,0,B,Bannink,Bernard,2271,FM,NED
9,=,W,Phillips,Roy,2271,,MAU
Total,4
This file can be parsed as follows.
data = open('tournament.csv') { |f| f.read }
parser = ICU::Tournament::ForeignCSV.new
tournament = parser.parse(data)
If the file is correctly specified, the return value from the parse method is an instance of
ICU::Tournament (rather than nil, which indicates an error). In this example the file is valid, so:
tournament.name # => "Isle of Man Masters, 2007"
tournament.start # => "2007-09-22"
tournament.rounds # => 9
tournament.website # => "http://www.bcmchess.co.uk/monarch2007/"
The main player (the player whose results are being reported for rating) played 9 rounds
but only 8 other players (he had a bye in round 6), so the total number of players is 9.
tournament.players.size # => 9
Each player has a unique number for the tournament. The main player always occurs first in this type of file, so his number is 1.
player = tournament.player(1)
player.name # => "Fox, Anthony"
This player has 4 points from 9 rounds but only 8 of his results are are rateable (because of the bye).
player.points # => 4.0
player.results.size # => 9
player.results.find_all{ |r| r.rateable }.size # => 8
The other players all have numbers greater than 1.
opponents = tournamnet.players.reject { |o| o.num == 1 }
There are 8 opponents of the main player, each with exactly one game.
opponents.size # => 8
opponents.find_all{ |o| o.results.size == 1}.size # => 8
However, none of the opponents' results are rateable. For example:
opponent = tournament.players(2)
opponent.name # => "Taylor, Peter P."
opponent.results[0].rateable # => false
=end
class ForeignCSV
attr_reader :error
# Parse CSV data returning a Tournament on success or a nil on failure.
# In the case of failure, an error message can be retrived via the error method.
def parse(csv)
begin
parse!(csv)
rescue => ex
@error = ex.message
nil
end
end
# Parse CSV data returning a Tournament on success or raising an exception on error.
def parse!(csv)
@state, @line, @round, @sum, @error = 0, 0, nil, nil, nil
@tournament = Tournament.new('Dummy', '2000-01-01')
FasterCSV.parse(csv, :row_sep => :auto) do |r|
@line += 1 # increment line number
next if r.size == 0 # skip empty lines
r = r.map{|c| c.nil? ? '' : c.strip} # trim all spaces, turn nils to blanks
next if r[0] == '' # skip blanks in column 1
@r = r # remember this record for later
begin
case @state
when 0 then event
when 1 then start
when 2 then rounds
when 3 then website
when 4 then player
when 5 then result
when 6 then total
else raise "internal error - state #{@state} does not exist"
end
rescue => err
raise err.class, "line #{@line}: #{err.message}", err.backtrace unless err.message.match(/^line [1-9]/)
raise
end
end
unless @state == 4
exp = case @state
when 0 then "the event name"
when 1 then "the start date"
when 2 then "the number of rounds"
when 3 then "the website address"
when 5 then "a result for round #{@round+1}"
when 6 then "a total score"
end
raise "line #{@line}: premature termination - expected #{exp}"
end
raise "line #{@line}: no players found in file" if @tournament.players.size == 0
@tournament
end
private
def event
abort "the 'Event' keyword", 0 unless @r[0].match(/^(Event|Tournament)$/i)
abort "the event name", 1 unless @r.size > 1 && @r[1] != ''
@tournament.name = @r[1]
@state = 1
end
def start
abort "the 'Start' keyword", 0 unless @r[0].match(/^(Start(\s+Date)?|Date)$/i)
abort "the start date", 1 unless @r.size > 1 && @r[1] != ''
@tournament.start = @r[1]
@state = 2
end
def rounds
abort "the 'Rounds' keyword", 0 unless @r[0].match(/(Number of )?Rounds$/)
abort "the number of rounds", 1 unless @r.size > 1 && @r[1].match(/^[1-9]\d*/)
@tournament.rounds = @r[1]
@state = 3
end
def website
abort "the 'Website' keyword", 0 unless @r[0].match(/^(Web(\s?site)?|Site)$/i)
abort "the event website", 1 unless @r.size > 1 && @r[1] != ''
@tournament.site = @r[1]
@state = 4
end
def player
abort "the 'Player' keyword", 0 unless @r[0].match(/^Player$/i)
abort "a player's ICU number", 1 unless @r.size > 1 && @r[1].match(/^[1-9]/i)
abort "a player's last name", 2 unless @r.size > 2 && @r[2].match(/[a-z]/i)
abort "a player's first name", 3 unless @r.size > 3 && @r[3].match(/[a-z]/i)
@player = Player.new(@r[3], @r[2], @tournament.players.size + 1, :id => @r[1])
old_player = @tournament.find_player(@player)
if old_player
raise "two players with the same name (#{@player.name}) have conflicting details" unless old_player.eql?(@player)
raise "same player (#{@player.name}) has more than one set of results" if old_player.id
old_player.merge(@player)
@player = old_player
else
@tournament.add_player(@player)
end
@round = 0
@state = 5
end
def result
@round+= 1
abort "round number #{round}", 0 unless @r[0].to_i == @round
abort "a colour (W/B) or dash (for a bye)", 2 unless @r.size > 2 && @r[2].match(/^(W|B|-)/i)
result = Result.new(@round, @player.num, @r[1])
if @r[2] == '-'
@tournament.add_result(result)
else
result.colour = @r[2]
opponent = Player.new(@r[4], @r[3], @tournament.players.size + 1, :rating => @r[5], :title => @r[6], :fed => @r[7])
raise "opponent must have a rating and federation" unless opponent.rating && opponent.fed
old_player = @tournament.find_player(opponent)
if old_player
raise "two players with the same name (#{opponent.name}) have conflicting details" unless old_player.eql?(opponent)
result.opponent = old_player.num
if old_player.id
old_player.merge(opponent)
old_result = @player.find_result(@round)
raise "missing result for player (#{@player.name}) in round #{@round}" unless old_result
raise "mismatched results for player (#{old_player.name}) in round #{@round}" unless result == old_result
old_result.rateable = true
else
old_result = old_player.find_result(@round)
raise "a player (#{old_player.name}) has more than one game in the same round (#{@round})" if old_result
@tournament.add_result(result, false)
end
else
@tournament.add_player(opponent)
result.opponent = opponent.num
@tournament.add_result(result, false)
end
end
@state = 6 if @round == @tournament.rounds
end
def total
points = @player.points
abort "the 'Total' keyword", 0 unless @r[0].match(/^Total$/i)
abort "the player's (#{@player.object_id}, #{@player.results.size}) total points to be #{points}", 1 unless @r[1].to_f == points
@state = 4
end
def abort(expected, cell)
got = @r[cell]
error = "line #{@line}"
error << ", cell #{cell+1}"
error << ": expected #{expected}"
error << " but got #{got == '' ? 'a blank cell' : "'#{got}'"}"
raise error
end
end
end
end