icu_tournament 0.8.9
Sign up to get free protection for your applications and to get access to all the features.
- data/.gitignore +4 -0
- data/LICENCE +22 -0
- data/README.rdoc +75 -0
- data/Rakefile +57 -0
- data/VERSION.yml +5 -0
- data/lib/icu_tournament.rb +8 -0
- data/lib/icu_tournament/federation.rb +303 -0
- data/lib/icu_tournament/name.rb +274 -0
- data/lib/icu_tournament/player.rb +204 -0
- data/lib/icu_tournament/result.rb +191 -0
- data/lib/icu_tournament/team.rb +90 -0
- data/lib/icu_tournament/tournament.rb +508 -0
- data/lib/icu_tournament/tournament_fcsv.rb +310 -0
- data/lib/icu_tournament/tournament_krause.rb +329 -0
- data/lib/icu_tournament/util.rb +156 -0
- data/spec/federation_spec.rb +176 -0
- data/spec/name_spec.rb +208 -0
- data/spec/player_spec.rb +313 -0
- data/spec/result_spec.rb +203 -0
- data/spec/spec_helper.rb +3 -0
- data/spec/team_spec.rb +60 -0
- data/spec/tournament_fcsv_spec.rb +548 -0
- data/spec/tournament_krause_spec.rb +379 -0
- data/spec/tournament_spec.rb +733 -0
- data/spec/util_spec.rb +357 -0
- metadata +97 -0
@@ -0,0 +1,191 @@
|
|
1
|
+
# encoding: utf-8
|
2
|
+
|
3
|
+
module ICU
|
4
|
+
|
5
|
+
=begin rdoc
|
6
|
+
|
7
|
+
== Result
|
8
|
+
|
9
|
+
A result is the outcome of a game from the perspective of one of the players.
|
10
|
+
If the game was not a bye or a walkover and involved a second player, then
|
11
|
+
that second player will also have a result for the same game, and the two
|
12
|
+
results will be mirror images of each other.
|
13
|
+
|
14
|
+
A result always involves a round number, a player number and a score, so these
|
15
|
+
three attributes must be supplied in the constructor.
|
16
|
+
|
17
|
+
result = ICU::Result.new(2, 10, 'W')
|
18
|
+
|
19
|
+
The above example represents player 10 winning in round 2. As it stands, it represends
|
20
|
+
a bye or walkover since there is no opponent. Without an opponent, it is unrateable.
|
21
|
+
|
22
|
+
result.rateable # => false
|
23
|
+
|
24
|
+
The player's colour and the number of their opponent can be set as follows:
|
25
|
+
|
26
|
+
result.colour = 'B'
|
27
|
+
result.opponent = 13
|
28
|
+
|
29
|
+
Specifying an opponent always makes a result rateable.
|
30
|
+
|
31
|
+
result.rateable # => true
|
32
|
+
|
33
|
+
This example now represents a win by player 10 with the black pieces over player number 13 in round 2.
|
34
|
+
Alternatively, all this can been specified in the constructor.
|
35
|
+
|
36
|
+
result = ICU::Result.new(2, 10, 'W', :opponent => 13, :colour => 'B')
|
37
|
+
|
38
|
+
To make a game unratable, even if it involves an opponent, set the _rateable_ atribute explicity:
|
39
|
+
|
40
|
+
result.rateable = false
|
41
|
+
|
42
|
+
or include it in the constructor:
|
43
|
+
|
44
|
+
result = ICU::Result.new(2, 10, 'W', :opponent => 13, :colour => 'B', :rateable => false)
|
45
|
+
|
46
|
+
The result of the same game from the perspective of the opponent is:
|
47
|
+
|
48
|
+
tluser = result.reverse
|
49
|
+
|
50
|
+
which, with the above example, would be:
|
51
|
+
|
52
|
+
tluser.player # => 13
|
53
|
+
tluser.opponent # => 10
|
54
|
+
tluser.score # => 'L'
|
55
|
+
tluser.colour # => 'B'
|
56
|
+
tluser.round # => 2
|
57
|
+
|
58
|
+
The reversed result copies the _rateable_ attribute of the original unless an
|
59
|
+
explicit override is supplied.
|
60
|
+
|
61
|
+
result.rateable # => true
|
62
|
+
result.reverse.rateable # => true (copied from original)
|
63
|
+
result.reverse(false).rateable # => false (overriden)
|
64
|
+
|
65
|
+
A result which has no opponent is not reversible (the _reverse_ method returns _nil_).
|
66
|
+
|
67
|
+
The return value from the _score_ method is always one of _W_, _L_ or _D_. However,
|
68
|
+
when setting the score, a certain amount of variation is permitted as long as it is
|
69
|
+
clear what is meant. For eample, the following would all be converted to _D_:
|
70
|
+
|
71
|
+
result.score = ' D '
|
72
|
+
result.score = 'd'
|
73
|
+
result.score = '='
|
74
|
+
result.score = '0.5'
|
75
|
+
|
76
|
+
The _points_ read-only accessor always returns a floating point number: either 0.0, 0.5 or 1.0.
|
77
|
+
|
78
|
+
=end
|
79
|
+
|
80
|
+
class Result
|
81
|
+
|
82
|
+
extend ICU::Accessor
|
83
|
+
attr_positive :round
|
84
|
+
attr_integer :player
|
85
|
+
|
86
|
+
attr_reader :score, :colour, :opponent, :rateable
|
87
|
+
|
88
|
+
# Constructor. Round number, player number and score must be supplied.
|
89
|
+
# Optional hash attribute are _opponent_, _colour_ and _rateable_.
|
90
|
+
def initialize(round, player, score, opt={})
|
91
|
+
self.round = round
|
92
|
+
self.player = player
|
93
|
+
self.score = score
|
94
|
+
[:colour, :opponent].each { |a| self.send("#{a}=", opt[a]) unless opt[a].nil? }
|
95
|
+
self.rateable = opt[:rateable] # always attempt to set this, and do it last, to get the right default
|
96
|
+
end
|
97
|
+
|
98
|
+
# Score for the game, even if a default. One of 'W', 'L' or 'D'. Reasonable inputs like 1, 0, =, ½, etc will be converted.
|
99
|
+
def score=(score)
|
100
|
+
@score = case score.to_s.strip
|
101
|
+
when /^(1\.0|1|\+|W|w)$/ then 'W'
|
102
|
+
when /^(0\.5|½|\=|D|d)$/ then 'D'
|
103
|
+
when /^(0\.0|0|\-|L|l)$/ then 'L'
|
104
|
+
else raise "invalid score (#{score})"
|
105
|
+
end
|
106
|
+
end
|
107
|
+
|
108
|
+
# Return the score as a floating point number.
|
109
|
+
def points
|
110
|
+
case @score
|
111
|
+
when 'W' then 1.0
|
112
|
+
when 'L' then 0.0
|
113
|
+
else 0.5
|
114
|
+
end
|
115
|
+
end
|
116
|
+
|
117
|
+
# Colour. Either 'W' (white) or 'B' (black).
|
118
|
+
def colour=(colour)
|
119
|
+
@colour = case colour.to_s
|
120
|
+
when '' then nil
|
121
|
+
when /W/i then 'W'
|
122
|
+
when /B/i then 'B'
|
123
|
+
else raise "invalid colour (#{colour})"
|
124
|
+
end
|
125
|
+
end
|
126
|
+
|
127
|
+
# Opponent player number. Either absent (_nil_) or any integer except the player number.
|
128
|
+
def opponent=(opponent)
|
129
|
+
@opponent = case opponent
|
130
|
+
when nil then nil
|
131
|
+
when Fixnum then opponent
|
132
|
+
when /^\s*$/ then nil
|
133
|
+
else opponent.to_i
|
134
|
+
end
|
135
|
+
raise "invalid opponent number (#{opponent})" if @opponent == 0 && !opponent.to_s.match(/\d/)
|
136
|
+
raise "opponent number and player number (#{@opponent}) must be different" if @opponent == player
|
137
|
+
self.rateable = true if @opponent
|
138
|
+
end
|
139
|
+
|
140
|
+
# Rateable flag. If false, result is not rateable. Can only be true if there is an opponent.
|
141
|
+
def rateable=(rateable)
|
142
|
+
if opponent.nil?
|
143
|
+
@rateable = false
|
144
|
+
return
|
145
|
+
end
|
146
|
+
@rateable = case rateable
|
147
|
+
when nil then true # default is true
|
148
|
+
when false then false # this is the only way to turn it off
|
149
|
+
else true
|
150
|
+
end
|
151
|
+
end
|
152
|
+
|
153
|
+
# Return a reversed version (from the opponent's perspective) of a result.
|
154
|
+
def reverse(rateable=nil)
|
155
|
+
return unless @opponent
|
156
|
+
r = Result.new(@round, @opponent, @score == 'W' ? 'L' : (@score == 'L' ? 'W' : 'D'))
|
157
|
+
r.opponent = @player
|
158
|
+
r.colour = 'W' if @colour == 'B'
|
159
|
+
r.colour = 'B' if @colour == 'W'
|
160
|
+
r.rateable = rateable || @rateable
|
161
|
+
r
|
162
|
+
end
|
163
|
+
|
164
|
+
# Renumber the player and opponent (if there is one) according to the supplied hash. Return self.
|
165
|
+
def renumber(map)
|
166
|
+
raise "result player number #{@player} not found in renumbering hash" unless map[@player]
|
167
|
+
self.player = map[@player]
|
168
|
+
if @opponent
|
169
|
+
raise "result opponent number #{@opponent} not found in renumbering hash" unless map[@opponent]
|
170
|
+
self.opponent = map[@opponent]
|
171
|
+
end
|
172
|
+
self
|
173
|
+
end
|
174
|
+
|
175
|
+
# Loose equality. True if the round, player and opponent numbers, colour and score all match.
|
176
|
+
def ==(other)
|
177
|
+
return unless other.is_a? Result
|
178
|
+
[:round, :player, :opponent, :colour, :score].each do |m|
|
179
|
+
return false unless self.send(m) == other.send(m)
|
180
|
+
end
|
181
|
+
true
|
182
|
+
end
|
183
|
+
|
184
|
+
# Strict equality. True if the there's loose equality and also the rateablity is the same.
|
185
|
+
def eql?(other)
|
186
|
+
return true if equal?(other)
|
187
|
+
return false unless self == other
|
188
|
+
self.rateable == other.rateable
|
189
|
+
end
|
190
|
+
end
|
191
|
+
end
|
@@ -0,0 +1,90 @@
|
|
1
|
+
module ICU
|
2
|
+
|
3
|
+
=begin rdoc
|
4
|
+
|
5
|
+
== Team
|
6
|
+
|
7
|
+
A team consists of a name and one or more players referenced by numbers.
|
8
|
+
Typically the team will be attached to a tournament (ICU::Tournament)
|
9
|
+
and the numbers will the unique numbers by which the players in that
|
10
|
+
tournament are referenced. To instantiate a team, you must supply a
|
11
|
+
name.
|
12
|
+
|
13
|
+
team = ICU::Team.new('Wandering Dragons')
|
14
|
+
|
15
|
+
Then you simply add player's (numbers) to it.
|
16
|
+
|
17
|
+
team.add_player(1)
|
18
|
+
team.add_payeer(3)
|
19
|
+
team.add_player(7)
|
20
|
+
|
21
|
+
To get the current members of a team
|
22
|
+
|
23
|
+
team.members # => [1, 3, 7]
|
24
|
+
|
25
|
+
You can enquire whether a team contains a given player number.
|
26
|
+
|
27
|
+
team.contains?(3) # => true
|
28
|
+
team.contains?(4) # => false
|
29
|
+
|
30
|
+
Or whether it matches a given name (which ignoring case and removing spurious whitespace)
|
31
|
+
|
32
|
+
team.matches(' wandering dragons ') # => true
|
33
|
+
team.matches('Blundering Bishops') # => false
|
34
|
+
|
35
|
+
Whenever you reset the name of a tournament spurious whitespace is removed but case is not altered.
|
36
|
+
|
37
|
+
team.name = ' blundering bishops '
|
38
|
+
team.name # => "blundering bishops"
|
39
|
+
|
40
|
+
Attempting to add non-numbers or duplicate numbers as new team members results in an exception.
|
41
|
+
|
42
|
+
team.add(nil) # exception - not a number
|
43
|
+
team.add(3) # exception - already a member
|
44
|
+
|
45
|
+
=end
|
46
|
+
|
47
|
+
class Team
|
48
|
+
|
49
|
+
attr_reader :name, :members
|
50
|
+
|
51
|
+
# Constructor. Name must be supplied.
|
52
|
+
def initialize(name)
|
53
|
+
self.name = name
|
54
|
+
@members = Array.new
|
55
|
+
end
|
56
|
+
|
57
|
+
# Set name. Must not be blank.
|
58
|
+
def name=(name)
|
59
|
+
@name = name.strip.squeeze(' ')
|
60
|
+
raise "team can't be blank" if @name.length == 0
|
61
|
+
end
|
62
|
+
|
63
|
+
# Add a team member referenced by any integer.
|
64
|
+
def add_member(number)
|
65
|
+
pnum = number.to_i
|
66
|
+
raise "'#{number}' is not a valid as a team member player number" if pnum == 0 && !number.to_s.match(/^[^\d]*0/)
|
67
|
+
raise "can't add duplicate player number #{pnum} to team '#{@name}'" if @members.include?(pnum)
|
68
|
+
@members.push(pnum)
|
69
|
+
end
|
70
|
+
|
71
|
+
# Renumber the players according to the supplied hash. Return self.
|
72
|
+
def renumber(map)
|
73
|
+
@members.each_with_index do |pnum, index|
|
74
|
+
raise "player number #{pnum} not found in renumbering hash" unless map[pnum]
|
75
|
+
@members[index] = map[pnum]
|
76
|
+
end
|
77
|
+
self
|
78
|
+
end
|
79
|
+
|
80
|
+
# Detect if a member exists in a team.
|
81
|
+
def include?(number)
|
82
|
+
@members.include?(number)
|
83
|
+
end
|
84
|
+
|
85
|
+
# Does the team name match the given string (ignoring case and spurious whitespace).
|
86
|
+
def matches(name)
|
87
|
+
self.name.downcase == name.strip.squeeze(' ').downcase
|
88
|
+
end
|
89
|
+
end
|
90
|
+
end
|
@@ -0,0 +1,508 @@
|
|
1
|
+
module ICU
|
2
|
+
|
3
|
+
=begin rdoc
|
4
|
+
|
5
|
+
== Building a Tournament
|
6
|
+
|
7
|
+
One way to create a tournament object is by parsing one of the supported file type (e.g. ICU::Tournament::Krause).
|
8
|
+
It is also possible to build one programmatically by
|
9
|
+
|
10
|
+
1. creating a bare tournament instance,
|
11
|
+
2. adding all the players and
|
12
|
+
3. adding all the results.
|
13
|
+
|
14
|
+
For example:
|
15
|
+
|
16
|
+
require 'rubygems'
|
17
|
+
require 'icu_tournament'
|
18
|
+
|
19
|
+
t = ICU::Tournament.new('Bangor Masters', '2009-11-09')
|
20
|
+
|
21
|
+
t.add_player(ICU::Player.new('Bobby', 'Fischer', 10))
|
22
|
+
t.add_player(ICU::Player.new('Garry', 'Kasparov', 20))
|
23
|
+
t.add_player(ICU::Player.new('Mark', 'Orr', 30))
|
24
|
+
|
25
|
+
t.add_result(ICU::Result.new(1, 10, 'D', :opponent => 30, :colour => 'W'))
|
26
|
+
t.add_result(ICU::Result.new(2, 20, 'W', :opponent => 30, :colour => 'B'))
|
27
|
+
t.add_result(ICU::Result.new(3, 20, 'L', :opponent => 10, :colour => 'W'))
|
28
|
+
|
29
|
+
t.validate!(:rerank => true)
|
30
|
+
|
31
|
+
and then:
|
32
|
+
|
33
|
+
serializer = ICU::Tournament::Krause.new
|
34
|
+
puts serializer.serialize(@t)
|
35
|
+
|
36
|
+
or equivalntly, just:
|
37
|
+
|
38
|
+
puts @t.serialize('Krause')
|
39
|
+
|
40
|
+
Would result in the following output:
|
41
|
+
|
42
|
+
012 Bangor Masters
|
43
|
+
042 2009-11-09
|
44
|
+
001 10 Fischer,Bobby 1.5 1 30 w = 20 b 1
|
45
|
+
001 20 Kasparov,Garry 1.0 2 30 b 1 10 w 0
|
46
|
+
001 30 Orr,Mark 0.5 3 10 b = 20 w 0
|
47
|
+
|
48
|
+
Note that the players should be added first because the _add_result_ method will
|
49
|
+
raise an exception if the players it references through their tournament numbers
|
50
|
+
(10, 20 and 30 in this example) have not already been added to the tournament.
|
51
|
+
|
52
|
+
See ICU::Player and ICU::Result for more details about players and results.
|
53
|
+
|
54
|
+
|
55
|
+
== Validation
|
56
|
+
|
57
|
+
A tournament can be validated with either the _validate!_ or _invalid_ methods.
|
58
|
+
On success, the first returns true while the second returns false.
|
59
|
+
On error, the first throws an exception while the second returns a string
|
60
|
+
describing the error.
|
61
|
+
|
62
|
+
Validations checks that:
|
63
|
+
|
64
|
+
* there are at least two players
|
65
|
+
* every player has a least one result
|
66
|
+
* the result round numbers are consistent (no more than one game per player per round)
|
67
|
+
* the tournament dates (start, finish, round dates), if there are any, are consistent
|
68
|
+
* the player ranks are consistent with their scores
|
69
|
+
|
70
|
+
Side effects of calling _validate!_ or _invalid_ include:
|
71
|
+
|
72
|
+
* the number of rounds will be set if not set already
|
73
|
+
* the finish date will be set if not set already and if there are round dates
|
74
|
+
|
75
|
+
|
76
|
+
== Ranking
|
77
|
+
|
78
|
+
They players in a tournament can be ranked by calling the _rerank_ method directly.
|
79
|
+
|
80
|
+
t.rerank
|
81
|
+
|
82
|
+
Alternatively they can be ranked as a side effect of validation if the _rerank_ option is set,
|
83
|
+
but this only applies if the tournament is not yet ranked or it's ranking is inconsistent.
|
84
|
+
|
85
|
+
t.validate(:rerank => true)
|
86
|
+
|
87
|
+
Ranking is inconsistent if some but not all players have a rank or if all players
|
88
|
+
have a rank but some are ranked higher than others on lower scores.
|
89
|
+
|
90
|
+
To rank the players requires a tie break method to be specified to order players on the same score.
|
91
|
+
The default is alphabetical (by last name then first name). Other methods can be specified by supplying
|
92
|
+
a list of methods (strings or symbols) in order of precedence to the _rerank_ method or for the _rerank_
|
93
|
+
option of the _validate_ method. Examples:
|
94
|
+
|
95
|
+
t.rerank('Sonneborn-Berger')
|
96
|
+
t.rerank(:buchholz, :neustadtl, :blacks, :wins)
|
97
|
+
|
98
|
+
t.validate(:rerank => :sonneborn_berger)
|
99
|
+
t.validate(:rerank => ['Modified Median', 'Neustadtl', 'Buchholz', 'wins'])
|
100
|
+
|
101
|
+
The full list of supported methods is:
|
102
|
+
|
103
|
+
* _Buchholz_: sum of opponents' scores
|
104
|
+
* _Harkness_ (or _median_): like Buchholz except the highest and lowest opponents' scores are discarded (or two highest and lowest if 9 rounds or more)
|
105
|
+
* _modified_median_: same as Harkness except only lowest (or highest) score(s) are discarded for players with more (or less) than 50%
|
106
|
+
* _Neustadtl_ (or _Sonneborn-Berger_): sum of scores of players defeated plus half sum of scores of players drawn against
|
107
|
+
* _blacks_: number of blacks
|
108
|
+
* _wins_: number of wins
|
109
|
+
* _name_: alphabetical by name is the default and is the same as calling _rerank_ with no options or setting the _rerank_ option to true
|
110
|
+
|
111
|
+
The return value from _rerank_ is the tournament object itself.
|
112
|
+
|
113
|
+
|
114
|
+
== Renumbering
|
115
|
+
|
116
|
+
The numbers used to uniquely identify each player in a tournament can be any set of unique integers
|
117
|
+
(including zero and negative numbers). To renumber the players so that these numbers start at 1 and
|
118
|
+
go up to the total number of players use the _renumber_ method. This method takes one optional
|
119
|
+
argument to specify how the renumbering is done.
|
120
|
+
|
121
|
+
t.renumber(:rank) # renumber by rank (if there are consistent rankings), otherwise by name alphabetically
|
122
|
+
t.renumber # the same, as renumbering by rank is the default
|
123
|
+
t.renumber(:name) # renumber by name alphabetically
|
124
|
+
|
125
|
+
The return value from _renumber_ is the tournament object itself.
|
126
|
+
|
127
|
+
=end
|
128
|
+
|
129
|
+
class Tournament
|
130
|
+
|
131
|
+
extend ICU::Accessor
|
132
|
+
attr_date :start
|
133
|
+
attr_date_or_nil :finish
|
134
|
+
attr_positive_or_nil :rounds
|
135
|
+
attr_string %r%[a-z]%i, :name
|
136
|
+
attr_string_or_nil %r%[a-z]%i, :city, :type, :arbiter, :deputy
|
137
|
+
attr_string_or_nil %r%[1-9]%i, :time_control
|
138
|
+
|
139
|
+
attr_reader :round_dates, :site, :fed, :teams
|
140
|
+
|
141
|
+
# Constructor. Name and start date must be supplied. Other attributes are optional.
|
142
|
+
def initialize(name, start, opt={})
|
143
|
+
self.name = name
|
144
|
+
self.start = start
|
145
|
+
[:finish, :rounds, :site, :city, :fed, :type, :arbiter, :deputy, :time_control].each { |a| self.send("#{a}=", opt[a]) unless opt[a].nil? }
|
146
|
+
@player = {}
|
147
|
+
@teams = []
|
148
|
+
@round_dates = []
|
149
|
+
end
|
150
|
+
|
151
|
+
# Set the tournament federation. Can be _nil_.
|
152
|
+
def fed=(fed)
|
153
|
+
obj = Federation.find(fed)
|
154
|
+
@fed = obj ? obj.code : nil
|
155
|
+
raise "invalid tournament federation (#{fed})" if @fed.nil? && fed.to_s.strip.length > 0
|
156
|
+
end
|
157
|
+
|
158
|
+
# Add a round date.
|
159
|
+
def add_round_date(round_date)
|
160
|
+
round_date = round_date.to_s.strip
|
161
|
+
parsed_date = Util.parsedate(round_date)
|
162
|
+
raise "invalid round date (#{round_date})" unless parsed_date
|
163
|
+
@round_dates << parsed_date
|
164
|
+
@round_dates.sort!
|
165
|
+
end
|
166
|
+
|
167
|
+
# Return the date of a given round, or nil if unavailable.
|
168
|
+
def round_date(round)
|
169
|
+
@round_dates[round-1]
|
170
|
+
end
|
171
|
+
|
172
|
+
# Return the greatest round number according to the players results (which may not be the same as the set number of rounds).
|
173
|
+
def last_round
|
174
|
+
last_round = 0
|
175
|
+
@player.values.each do |p|
|
176
|
+
p.results.each do |r|
|
177
|
+
last_round = r.round if r.round > last_round
|
178
|
+
end
|
179
|
+
end
|
180
|
+
last_round
|
181
|
+
end
|
182
|
+
|
183
|
+
# Set the tournament web site. Should be either unknown (_nil_) or a reasonably valid looking URL.
|
184
|
+
def site=(site)
|
185
|
+
@site = site.to_s.strip
|
186
|
+
@site = nil if @site == ''
|
187
|
+
@site = "http://#{@site}" if @site && !@site.match(/^https?:\/\//)
|
188
|
+
raise "invalid site (#{site})" unless @site.nil? || @site.match(/^https?:\/\/[-\w]+(\.[-\w]+)+(\/[^\s]*)?$/i)
|
189
|
+
end
|
190
|
+
|
191
|
+
# Add a new team. The argument is either a team (possibly already with members) or the name of a new team.
|
192
|
+
# The team's name must be unique in the tournament. Returns the the team instance.
|
193
|
+
def add_team(team)
|
194
|
+
team = Team.new(team.to_s) unless team.is_a? Team
|
195
|
+
raise "a team with a name similar to '#{team.name}' already exists" if self.get_team(team.name)
|
196
|
+
@teams << team
|
197
|
+
team
|
198
|
+
end
|
199
|
+
|
200
|
+
# Return the team object that matches a given name, or nil if not found.
|
201
|
+
def get_team(name)
|
202
|
+
@teams.find{ |t| t.matches(name) }
|
203
|
+
end
|
204
|
+
|
205
|
+
# Add a new player to the tournament. Must have a unique player number.
|
206
|
+
def add_player(player)
|
207
|
+
raise "invalid player" unless player.class == ICU::Player
|
208
|
+
raise "player number (#{player.num}) should be unique" if @player[player.num]
|
209
|
+
@player[player.num] = player
|
210
|
+
end
|
211
|
+
|
212
|
+
# Get a player by their number.
|
213
|
+
def player(num)
|
214
|
+
@player[num]
|
215
|
+
end
|
216
|
+
|
217
|
+
# Return an array of all players in order of their player number.
|
218
|
+
def players
|
219
|
+
@player.values.sort_by{ |p| p.num }
|
220
|
+
end
|
221
|
+
|
222
|
+
# Lookup a player in the tournament by player number, returning _nil_ if the player number does not exist.
|
223
|
+
def find_player(player)
|
224
|
+
players.find { |p| p == player }
|
225
|
+
end
|
226
|
+
|
227
|
+
# Add a result to a tournament. An exception is raised if the players referenced in the result (by number)
|
228
|
+
# do not exist in the tournament. The result, which remember is from the perspective of one of the players,
|
229
|
+
# is added to that player's results. Additionally, the reverse of the result is automatically added to the player's
|
230
|
+
# opponent, unless the opponent does not exist (e.g. byes, walkovers). By default, if the result is rateable
|
231
|
+
# then the opponent's result will also be rateable. To make the opponent's result unrateable, set the optional
|
232
|
+
# second parameter to false.
|
233
|
+
def add_result(result, reverse_rateable=true)
|
234
|
+
raise "invalid result" unless result.class == ICU::Result
|
235
|
+
raise "result round number (#{result.round}) inconsistent with number of tournament rounds" if @rounds && result.round > @rounds
|
236
|
+
raise "player number (#{result.player}) does not exist" unless @player[result.player]
|
237
|
+
@player[result.player].add_result(result)
|
238
|
+
if result.opponent
|
239
|
+
raise "opponent number (#{result.opponent}) does not exist" unless @player[result.opponent]
|
240
|
+
reverse = result.reverse
|
241
|
+
reverse.rateable = false unless reverse_rateable
|
242
|
+
@player[result.opponent].add_result(reverse)
|
243
|
+
end
|
244
|
+
end
|
245
|
+
|
246
|
+
# Rerank the tournament by score first and if necessary using a configurable tie breaker method.
|
247
|
+
def rerank(*tie_break_methods)
|
248
|
+
tie_break_methods, tie_break_order, tie_break_hash = tie_break_data(tie_break_methods.flatten)
|
249
|
+
@player.values.sort do |a,b|
|
250
|
+
cmp = 0
|
251
|
+
tie_break_methods.each do |m|
|
252
|
+
cmp = (tie_break_hash[m][a.num] <=> tie_break_hash[m][b.num]) * tie_break_order[m] if cmp == 0
|
253
|
+
end
|
254
|
+
cmp
|
255
|
+
end.each_with_index do |p,i|
|
256
|
+
p.rank = i + 1
|
257
|
+
end
|
258
|
+
self
|
259
|
+
end
|
260
|
+
|
261
|
+
# Return a hash of tie break scores (player number to value).
|
262
|
+
def tie_break_scores(*tie_break_methods)
|
263
|
+
tie_break_methods, tie_break_order, tie_break_hash = tie_break_data(tie_break_methods)
|
264
|
+
main_method = tie_break_methods[1]
|
265
|
+
scores = Hash.new
|
266
|
+
@player.values.each { |p| scores[p.num] = tie_break_hash[main_method][p.num] }
|
267
|
+
scores
|
268
|
+
end
|
269
|
+
|
270
|
+
# Renumber the players according to a given criterion.
|
271
|
+
def renumber(criterion = :rank)
|
272
|
+
map = Hash.new
|
273
|
+
|
274
|
+
# Decide how to renumber.
|
275
|
+
criterion = criterion.to_s.downcase
|
276
|
+
if criterion.match('rank')
|
277
|
+
# Renumber by rank if possible.
|
278
|
+
begin check_ranks rescue criterion = 'name' end
|
279
|
+
@player.values.each{ |p| map[p.num] = p.rank }
|
280
|
+
end
|
281
|
+
if !criterion.match('rank')
|
282
|
+
# Renumber by name alphabetically.
|
283
|
+
@player.values.sort_by{ |p| p.name }.each_with_index{ |p, i| map[p.num] = i + 1 }
|
284
|
+
end
|
285
|
+
|
286
|
+
# Apply renumbering.
|
287
|
+
@teams.each{ |t| t.renumber(map) }
|
288
|
+
@player = @player.values.inject({}) do |hash, player|
|
289
|
+
player.renumber(map)
|
290
|
+
hash[player.num] = player
|
291
|
+
hash
|
292
|
+
end
|
293
|
+
|
294
|
+
self
|
295
|
+
end
|
296
|
+
|
297
|
+
# Is a tournament invalid? Either returns false (if it's valid) or an error message.
|
298
|
+
# Has the same _rerank_ option as validate!.
|
299
|
+
def invalid(options={})
|
300
|
+
begin
|
301
|
+
validate!(options)
|
302
|
+
rescue => err
|
303
|
+
return err.message
|
304
|
+
end
|
305
|
+
false
|
306
|
+
end
|
307
|
+
|
308
|
+
# Raise an exception if a tournament is not valid.
|
309
|
+
# The _rerank_ option can be set to _true_ or one of the valid tie-break
|
310
|
+
# methods to rerank the tournament if ranking is missing or inconsistent.
|
311
|
+
def validate!(options={})
|
312
|
+
begin check_ranks rescue rerank(options[:rerank]) end if options[:rerank]
|
313
|
+
check_players
|
314
|
+
check_rounds
|
315
|
+
check_dates
|
316
|
+
check_teams
|
317
|
+
check_ranks(:allow_none => true)
|
318
|
+
true
|
319
|
+
end
|
320
|
+
|
321
|
+
# Convenience method to serialise the tournament into a supported format.
|
322
|
+
# Throws and exception unless the name of a supported format is supplied (e.g. _Krause_).
|
323
|
+
def serialize(format)
|
324
|
+
serializer = case format.to_s.downcase
|
325
|
+
when 'krause' then ICU::Tournament::Krause.new
|
326
|
+
when 'foreigncsv' then ICU::Tournament::ForeignCSV.new
|
327
|
+
else raise "unsupported serialisation format: '#{format}'"
|
328
|
+
end
|
329
|
+
serializer.serialize(self)
|
330
|
+
end
|
331
|
+
|
332
|
+
private
|
333
|
+
|
334
|
+
# Check players.
|
335
|
+
def check_players
|
336
|
+
raise "the number of players (#{@player.size}) must be at least 2" if @player.size < 2
|
337
|
+
@player.each do |num, p|
|
338
|
+
raise "player #{num} has no results" if p.results.size == 0
|
339
|
+
p.results.each do |r|
|
340
|
+
next unless r.opponent
|
341
|
+
raise "opponent #{r.opponent} of player #{num} is not in the tournament" unless @player[r.opponent]
|
342
|
+
end
|
343
|
+
end
|
344
|
+
end
|
345
|
+
|
346
|
+
# Round should go from 1 to a maximum, there should be at least one result in every round and,
|
347
|
+
# if the number of rounds has been set, it should agree with the largest round from the results.
|
348
|
+
def check_rounds
|
349
|
+
round = Hash.new
|
350
|
+
round_last = last_round
|
351
|
+
@player.values.each do |p|
|
352
|
+
p.results.each do |r|
|
353
|
+
round[r.round] = true
|
354
|
+
end
|
355
|
+
end
|
356
|
+
(1..round_last).each { |r| raise "there are no results for round #{r}" unless round[r] }
|
357
|
+
if rounds
|
358
|
+
raise "declared number of rounds is #{rounds} but there are results in later rounds, such as #{round_last}" if rounds < round_last
|
359
|
+
raise "declared number of rounds is #{rounds} but there are no results with rounds greater than #{round_last}" if rounds > round_last
|
360
|
+
else
|
361
|
+
self.rounds = round_last
|
362
|
+
end
|
363
|
+
end
|
364
|
+
|
365
|
+
# Check dates are consistent.
|
366
|
+
def check_dates
|
367
|
+
raise "start date (#{start}) is after end date (#{finish})" if @start && @finish && @start > @finish
|
368
|
+
if @round_dates.size > 0
|
369
|
+
raise "the number of round dates (#{@round_dates.size}) does not match the number of rounds (#{@rounds})" unless @round_dates.size == @rounds
|
370
|
+
raise "the date of the first round (#{@round_dates[0]}) comes before the start (#{@start}) of the tournament" if @start && @start > @round_dates[0]
|
371
|
+
raise "the date of the last round (#{@round_dates[-1]}) comes after the end (#{@finish}) of the tournament" if @finish && @finish < @round_dates[-1]
|
372
|
+
@finish = @round_dates[-1] unless @finish
|
373
|
+
end
|
374
|
+
end
|
375
|
+
|
376
|
+
# Check teams. Either there are none or:
|
377
|
+
# * every team member is a valid player, and
|
378
|
+
# * every player is a member of exactly one team.
|
379
|
+
def check_teams
|
380
|
+
return if @teams.size == 0
|
381
|
+
member = Hash.new
|
382
|
+
@teams.each do |t|
|
383
|
+
t.members.each do |m|
|
384
|
+
raise "member #{m} of team '#{t.name}' is not a valid player number for this tournament" unless @player[m]
|
385
|
+
raise "member #{m} of team '#{t.name}' is already a member of team #{member[m]}" if member[m]
|
386
|
+
member[m] = t.name
|
387
|
+
end
|
388
|
+
end
|
389
|
+
@player.keys.each do |p|
|
390
|
+
raise "player #{p} is not a member of any team" unless member[p]
|
391
|
+
end
|
392
|
+
end
|
393
|
+
|
394
|
+
# Check if the players ranking is consistent, which will be true if:
|
395
|
+
# * every player has a rank
|
396
|
+
# * no two players have the same rank
|
397
|
+
# * the highest rank is 1
|
398
|
+
# * the lowest rank is equal to the total of players
|
399
|
+
def check_ranks(options={})
|
400
|
+
ranks = Hash.new
|
401
|
+
@player.values.each do |p|
|
402
|
+
if p.rank
|
403
|
+
raise "two players have the same rank #{p.rank}" if ranks[p.rank]
|
404
|
+
ranks[p.rank] = p
|
405
|
+
end
|
406
|
+
end
|
407
|
+
return if ranks.size == 0 && options[:allow_none]
|
408
|
+
raise "every player has to have a rank" unless ranks.size == @player.size
|
409
|
+
by_rank = @player.values.sort{ |a,b| a.rank <=> b.rank}
|
410
|
+
raise "the highest rank must be 1" unless by_rank[0].rank == 1
|
411
|
+
raise "the lowest rank must be #{ranks.size}" unless by_rank[-1].rank == ranks.size
|
412
|
+
if by_rank.size > 1
|
413
|
+
(1..by_rank.size-1).each do |i|
|
414
|
+
p1 = by_rank[i-1]
|
415
|
+
p2 = by_rank[i]
|
416
|
+
raise "player #{p1.num} with #{p1.points} points is ranked above player #{p2.num} with #{p2.points} points" if p1.points < p2.points
|
417
|
+
end
|
418
|
+
end
|
419
|
+
end
|
420
|
+
|
421
|
+
# Return an array of tie break methods and an array of tie break orders (+1 for asc, -1 for desc).
|
422
|
+
# The first and most important method is always "score", the last and least important is always "name".
|
423
|
+
def tie_break_data(tie_break_methods)
|
424
|
+
# Canonicalise the tie break method names.
|
425
|
+
tie_break_methods.map! do |m|
|
426
|
+
m = m.to_s if m.class == Symbol
|
427
|
+
m = m.downcase.gsub(/[-\s]/, '_') if m.class == String
|
428
|
+
case m
|
429
|
+
when true then 'name'
|
430
|
+
when 'sonneborn_berger' then 'neustadtl'
|
431
|
+
when 'modified_median' then 'modified'
|
432
|
+
when 'median' then 'harkness'
|
433
|
+
else m
|
434
|
+
end
|
435
|
+
end
|
436
|
+
|
437
|
+
# Check they're all valid.
|
438
|
+
tie_break_methods.each { |m| raise "invalid tie break method '#{m}'" unless m.match(/^(blacks|buchholz|harkness|modified|name|neustadtl|wins)$/) }
|
439
|
+
|
440
|
+
# Construct the arrays and hashes to be returned.
|
441
|
+
methods, order, data = Array.new, Hash.new, Hash.new
|
442
|
+
|
443
|
+
# Score is always the most important.
|
444
|
+
methods << 'score'
|
445
|
+
order['score'] = -1
|
446
|
+
|
447
|
+
# Add the configured methods.
|
448
|
+
tie_break_methods.each do |m|
|
449
|
+
methods << m
|
450
|
+
order[m] = -1
|
451
|
+
end
|
452
|
+
|
453
|
+
# Name is included as the last and least important tie breaker unless it's already been added.
|
454
|
+
unless methods.include?('name')
|
455
|
+
methods << 'name'
|
456
|
+
order['name'] = +1
|
457
|
+
end
|
458
|
+
|
459
|
+
# We'll need the number of rounds.
|
460
|
+
rounds = last_round
|
461
|
+
|
462
|
+
# Pre-calculate some scores that are not in themselves tie break score
|
463
|
+
# but are needed in the calculation of some of the actual tie-break scores.
|
464
|
+
pre_calculated = Array.new
|
465
|
+
pre_calculated << 'opp-score' # sum scores where a non-played games counts 0.5
|
466
|
+
pre_calculated.each do |m|
|
467
|
+
data[m] = Hash.new
|
468
|
+
@player.values.each do |p|
|
469
|
+
data[m][p.num] = tie_break_score(data, m, p, rounds)
|
470
|
+
end
|
471
|
+
end
|
472
|
+
|
473
|
+
# Now calculate all the other scores.
|
474
|
+
methods.each do |m|
|
475
|
+
next if pre_calculated.include?(m)
|
476
|
+
data[m] = Hash.new
|
477
|
+
@player.values.each { |p| data[m][p.num] = tie_break_score(data, m, p, rounds) }
|
478
|
+
end
|
479
|
+
|
480
|
+
# Finally, return what we calculated.
|
481
|
+
[methods, order, data]
|
482
|
+
end
|
483
|
+
|
484
|
+
# Return a tie break score for a given player and a given tie break method.
|
485
|
+
def tie_break_score(hash, method, player, rounds)
|
486
|
+
case method
|
487
|
+
when 'score' then player.points
|
488
|
+
when 'wins' then player.results.inject(0) { |t,r| t + (r.opponent && r.score == 'W' ? 1 : 0) }
|
489
|
+
when 'blacks' then player.results.inject(0) { |t,r| t + (r.opponent && r.colour == 'B' ? 1 : 0) }
|
490
|
+
when 'buchholz' then player.results.inject(0.0) { |t,r| t + (r.opponent ? hash['opp-score'][r.opponent] : 0.0) }
|
491
|
+
when 'neustadtl' then player.results.inject(0.0) { |t,r| t + (r.opponent ? hash['opp-score'][r.opponent] * r.points : 0.0) }
|
492
|
+
when 'opp-score' then player.results.inject(0.0) { |t,r| t + (r.opponent ? r.points : 0.5) } + (rounds - player.results.size) * 0.5
|
493
|
+
when 'harkness', 'modified'
|
494
|
+
scores = player.results.map{ |r| r.opponent ? hash['opp-score'][r.opponent] : 0.0 }.sort
|
495
|
+
1.upto(rounds - player.results.size) { scores << 0.0 }
|
496
|
+
half = rounds / 2.0
|
497
|
+
times = rounds >= 9 ? 2 : 1
|
498
|
+
if method == 'harkness' || player.points == half
|
499
|
+
1.upto(times) { scores.shift; scores.pop }
|
500
|
+
else
|
501
|
+
1.upto(times) { scores.send(player.points > half ? :shift : :pop) }
|
502
|
+
end
|
503
|
+
scores.inject(0.0) { |t,s| t + s }
|
504
|
+
else player.name
|
505
|
+
end
|
506
|
+
end
|
507
|
+
end
|
508
|
+
end
|