sashite-pcn 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +488 -279
- data/lib/sashite/pcn/game/meta.rb +94 -25
- data/lib/sashite/pcn/game/sides/player.rb +192 -10
- data/lib/sashite/pcn/game/sides.rb +347 -10
- data/lib/sashite/pcn/game.rb +157 -42
- data/lib/sashite/pcn.rb +2 -2
- metadata +5 -5
|
@@ -7,13 +7,42 @@ module Sashite
|
|
|
7
7
|
class Game
|
|
8
8
|
# Represents player information for both sides of a game
|
|
9
9
|
#
|
|
10
|
-
#
|
|
11
|
-
#
|
|
10
|
+
# Manages two Player objects (first and second) with support for
|
|
11
|
+
# player metadata, styles, and time control settings. Both players
|
|
12
|
+
# are optional and default to empty player objects.
|
|
12
13
|
#
|
|
13
|
-
# @example With both players
|
|
14
|
+
# @example With both players and time control
|
|
14
15
|
# sides = Sides.new(
|
|
15
|
-
# first: {
|
|
16
|
-
#
|
|
16
|
+
# first: {
|
|
17
|
+
# name: "Carlsen",
|
|
18
|
+
# elo: 2830,
|
|
19
|
+
# style: "CHESS",
|
|
20
|
+
# periods: [
|
|
21
|
+
# { time: 5400, moves: 40, inc: 0 },
|
|
22
|
+
# { time: 1800, moves: nil, inc: 30 }
|
|
23
|
+
# ]
|
|
24
|
+
# },
|
|
25
|
+
# second: {
|
|
26
|
+
# name: "Nakamura",
|
|
27
|
+
# elo: 2794,
|
|
28
|
+
# style: "chess",
|
|
29
|
+
# periods: [
|
|
30
|
+
# { time: 5400, moves: 40, inc: 0 },
|
|
31
|
+
# { time: 1800, moves: nil, inc: 30 }
|
|
32
|
+
# ]
|
|
33
|
+
# }
|
|
34
|
+
# )
|
|
35
|
+
#
|
|
36
|
+
# @example With Fischer time control (5+3 blitz)
|
|
37
|
+
# sides = Sides.new(
|
|
38
|
+
# first: {
|
|
39
|
+
# name: "Alice",
|
|
40
|
+
# periods: [{ time: 300, moves: nil, inc: 3 }]
|
|
41
|
+
# },
|
|
42
|
+
# second: {
|
|
43
|
+
# name: "Bob",
|
|
44
|
+
# periods: [{ time: 300, moves: nil, inc: 3 }]
|
|
45
|
+
# }
|
|
17
46
|
# )
|
|
18
47
|
#
|
|
19
48
|
# @example With only first player
|
|
@@ -22,13 +51,21 @@ module Sashite
|
|
|
22
51
|
# )
|
|
23
52
|
#
|
|
24
53
|
# @example Empty sides (no player information)
|
|
25
|
-
# sides = Sides.new # Both players default to
|
|
54
|
+
# sides = Sides.new # Both players default to empty
|
|
26
55
|
class Sides
|
|
56
|
+
# Error messages
|
|
57
|
+
ERROR_INVALID_FIRST = "first must be a hash"
|
|
58
|
+
ERROR_INVALID_SECOND = "second must be a hash"
|
|
59
|
+
|
|
27
60
|
# Create a new Sides instance
|
|
28
61
|
#
|
|
29
62
|
# @param first [Hash] first player information (defaults to {})
|
|
30
63
|
# @param second [Hash] second player information (defaults to {})
|
|
64
|
+
# @raise [ArgumentError] if parameters are not hashes
|
|
31
65
|
def initialize(first: {}, second: {})
|
|
66
|
+
raise ::ArgumentError, ERROR_INVALID_FIRST unless first.is_a?(::Hash)
|
|
67
|
+
raise ::ArgumentError, ERROR_INVALID_SECOND unless second.is_a?(::Hash)
|
|
68
|
+
|
|
32
69
|
@first = Player.new(**first.transform_keys(&:to_sym))
|
|
33
70
|
@second = Player.new(**second.transform_keys(&:to_sym))
|
|
34
71
|
|
|
@@ -40,7 +77,9 @@ module Sashite
|
|
|
40
77
|
# @return [Player] first player (may be empty)
|
|
41
78
|
#
|
|
42
79
|
# @example
|
|
43
|
-
# sides.first
|
|
80
|
+
# player = sides.first
|
|
81
|
+
# player.name # => "Carlsen"
|
|
82
|
+
# player.periods # => [{ time: 5400, moves: 40, inc: 0 }, ...]
|
|
44
83
|
def first
|
|
45
84
|
@first
|
|
46
85
|
end
|
|
@@ -50,11 +89,44 @@ module Sashite
|
|
|
50
89
|
# @return [Player] second player (may be empty)
|
|
51
90
|
#
|
|
52
91
|
# @example
|
|
53
|
-
# sides.second
|
|
92
|
+
# player = sides.second
|
|
93
|
+
# player.name # => "Nakamura"
|
|
94
|
+
# player.elo # => 2794
|
|
54
95
|
def second
|
|
55
96
|
@second
|
|
56
97
|
end
|
|
57
98
|
|
|
99
|
+
# Access player by index
|
|
100
|
+
#
|
|
101
|
+
# @param index [Integer] 0 for first, 1 for second
|
|
102
|
+
# @return [Player, nil] player or nil if index out of bounds
|
|
103
|
+
#
|
|
104
|
+
# @example
|
|
105
|
+
# sides[0] # => first player
|
|
106
|
+
# sides[1] # => second player
|
|
107
|
+
# sides[2] # => nil
|
|
108
|
+
def [](index)
|
|
109
|
+
case index
|
|
110
|
+
when 0 then @first
|
|
111
|
+
when 1 then @second
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Get player by side
|
|
116
|
+
#
|
|
117
|
+
# @param side [Symbol, String] :first or :second
|
|
118
|
+
# @return [Player, nil] player or nil if invalid side
|
|
119
|
+
#
|
|
120
|
+
# @example
|
|
121
|
+
# sides.player(:first) # => first player
|
|
122
|
+
# sides.player("second") # => second player
|
|
123
|
+
def player(side)
|
|
124
|
+
case side.to_sym
|
|
125
|
+
when :first then @first
|
|
126
|
+
when :second then @second
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
58
130
|
# Check if no player information is present
|
|
59
131
|
#
|
|
60
132
|
# @return [Boolean] true if both players are empty
|
|
@@ -65,6 +137,161 @@ module Sashite
|
|
|
65
137
|
@first.empty? && @second.empty?
|
|
66
138
|
end
|
|
67
139
|
|
|
140
|
+
# Check if both players have information
|
|
141
|
+
#
|
|
142
|
+
# @return [Boolean] true if both players have data
|
|
143
|
+
#
|
|
144
|
+
# @example
|
|
145
|
+
# sides.complete? # => true
|
|
146
|
+
def complete?
|
|
147
|
+
!@first.empty? && !@second.empty?
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Check if both players have same time control
|
|
151
|
+
#
|
|
152
|
+
# @return [Boolean] true if periods match
|
|
153
|
+
#
|
|
154
|
+
# @example
|
|
155
|
+
# sides.symmetric_time_control? # => true
|
|
156
|
+
def symmetric_time_control?
|
|
157
|
+
@first.periods == @second.periods
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# Check if both players have time control
|
|
161
|
+
#
|
|
162
|
+
# @return [Boolean] true if both have periods defined
|
|
163
|
+
#
|
|
164
|
+
# @example
|
|
165
|
+
# sides.both_have_time_control? # => true
|
|
166
|
+
def both_have_time_control?
|
|
167
|
+
@first.has_time_control? && @second.has_time_control?
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# Check if neither player has time control
|
|
171
|
+
#
|
|
172
|
+
# @return [Boolean] true if both have unlimited time
|
|
173
|
+
#
|
|
174
|
+
# @example
|
|
175
|
+
# sides.unlimited_game? # => false
|
|
176
|
+
def unlimited_game?
|
|
177
|
+
@first.unlimited_time? && @second.unlimited_time?
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# Check if players have different time controls
|
|
181
|
+
#
|
|
182
|
+
# Returns true when the two players have different time control settings.
|
|
183
|
+
# This is the logical opposite of symmetric_time_control?.
|
|
184
|
+
#
|
|
185
|
+
# @return [Boolean] true if time controls differ
|
|
186
|
+
#
|
|
187
|
+
# @example Different periods
|
|
188
|
+
# # First: 5+3 blitz, Second: 10 minutes
|
|
189
|
+
# sides.mixed_time_control? # => true
|
|
190
|
+
#
|
|
191
|
+
# @example One with time control, one without
|
|
192
|
+
# # First: 5+3 blitz, Second: nil (unlimited)
|
|
193
|
+
# sides.mixed_time_control? # => true
|
|
194
|
+
#
|
|
195
|
+
# @example Both unlimited (but different representation)
|
|
196
|
+
# # First: [], Second: nil
|
|
197
|
+
# sides.mixed_time_control? # => true
|
|
198
|
+
#
|
|
199
|
+
# @example Identical time controls
|
|
200
|
+
# # Both: 5+3 blitz
|
|
201
|
+
# sides.mixed_time_control? # => false
|
|
202
|
+
#
|
|
203
|
+
# @example Both unlimited (same representation)
|
|
204
|
+
# # Both: nil
|
|
205
|
+
# sides.mixed_time_control? # => false
|
|
206
|
+
def mixed_time_control?
|
|
207
|
+
!symmetric_time_control?
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# Get both players' names
|
|
211
|
+
#
|
|
212
|
+
# @return [Array<String>] array of [first_name, second_name]
|
|
213
|
+
#
|
|
214
|
+
# @example
|
|
215
|
+
# sides.names # => ["Carlsen", "Nakamura"]
|
|
216
|
+
def names
|
|
217
|
+
[@first.name, @second.name]
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# Get both players' Elo ratings
|
|
221
|
+
#
|
|
222
|
+
# @return [Array<Integer>] array of [first_elo, second_elo]
|
|
223
|
+
#
|
|
224
|
+
# @example
|
|
225
|
+
# sides.elos # => [2830, 2794]
|
|
226
|
+
def elos
|
|
227
|
+
[@first.elo, @second.elo]
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
# Get both players' styles
|
|
231
|
+
#
|
|
232
|
+
# @return [Array<String>] array of [first_style, second_style]
|
|
233
|
+
#
|
|
234
|
+
# @example
|
|
235
|
+
# sides.styles # => ["CHESS", "chess"]
|
|
236
|
+
def styles
|
|
237
|
+
[
|
|
238
|
+
@first.style&.to_s,
|
|
239
|
+
@second.style&.to_s
|
|
240
|
+
]
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
# Get both players' time budgets
|
|
244
|
+
#
|
|
245
|
+
# @return [Array<Integer>] array of [first_time, second_time]
|
|
246
|
+
#
|
|
247
|
+
# @example
|
|
248
|
+
# sides.time_budgets # => [7200, 7200]
|
|
249
|
+
def time_budgets
|
|
250
|
+
[
|
|
251
|
+
@first.initial_time_budget,
|
|
252
|
+
@second.initial_time_budget
|
|
253
|
+
]
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
# Iterate over both players
|
|
257
|
+
#
|
|
258
|
+
# @yield [player] yields each player
|
|
259
|
+
# @return [Enumerator] if no block given
|
|
260
|
+
#
|
|
261
|
+
# @example
|
|
262
|
+
# sides.each { |player| puts player.name }
|
|
263
|
+
# sides.each.with_index { |player, i| puts "Player #{i+1}: #{player.name}" }
|
|
264
|
+
def each
|
|
265
|
+
return enum_for(:each) unless block_given?
|
|
266
|
+
|
|
267
|
+
yield @first
|
|
268
|
+
yield @second
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
# Map over both players
|
|
272
|
+
#
|
|
273
|
+
# @yield [player] yields each player
|
|
274
|
+
# @return [Array] results of block for each player
|
|
275
|
+
#
|
|
276
|
+
# @example
|
|
277
|
+
# sides.map(&:name) # => ["Carlsen", "Nakamura"]
|
|
278
|
+
# sides.map(&:elo) # => [2830, 2794]
|
|
279
|
+
def map(&)
|
|
280
|
+
return enum_for(:map) unless block_given?
|
|
281
|
+
|
|
282
|
+
[@first, @second].map(&)
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
# Get array of both players
|
|
286
|
+
#
|
|
287
|
+
# @return [Array<Player>] [first, second]
|
|
288
|
+
#
|
|
289
|
+
# @example
|
|
290
|
+
# sides.to_a # => [#<Player ...>, #<Player ...>]
|
|
291
|
+
def to_a
|
|
292
|
+
[@first, @second]
|
|
293
|
+
end
|
|
294
|
+
|
|
68
295
|
# Convert to hash representation
|
|
69
296
|
#
|
|
70
297
|
# Returns a hash containing only non-empty player objects.
|
|
@@ -74,8 +301,20 @@ module Sashite
|
|
|
74
301
|
#
|
|
75
302
|
# @example With both players
|
|
76
303
|
# sides.to_h
|
|
77
|
-
# # => {
|
|
78
|
-
# #
|
|
304
|
+
# # => {
|
|
305
|
+
# # first: {
|
|
306
|
+
# # name: "Carlsen",
|
|
307
|
+
# # elo: 2830,
|
|
308
|
+
# # style: "CHESS",
|
|
309
|
+
# # periods: [{ time: 5400, moves: 40, inc: 0 }]
|
|
310
|
+
# # },
|
|
311
|
+
# # second: {
|
|
312
|
+
# # name: "Nakamura",
|
|
313
|
+
# # elo: 2794,
|
|
314
|
+
# # style: "chess",
|
|
315
|
+
# # periods: [{ time: 5400, moves: 40, inc: 0 }]
|
|
316
|
+
# # }
|
|
317
|
+
# # }
|
|
79
318
|
#
|
|
80
319
|
# @example With only first player
|
|
81
320
|
# sides.to_h
|
|
@@ -90,6 +329,104 @@ module Sashite
|
|
|
90
329
|
result[:second] = @second.to_h unless @second.empty?
|
|
91
330
|
result
|
|
92
331
|
end
|
|
332
|
+
|
|
333
|
+
# String representation for debugging
|
|
334
|
+
#
|
|
335
|
+
# @return [String] string representation
|
|
336
|
+
def inspect
|
|
337
|
+
players = []
|
|
338
|
+
players << "first=#{@first.name || '(empty)'}"
|
|
339
|
+
players << "second=#{@second.name || '(empty)'}"
|
|
340
|
+
|
|
341
|
+
"#<#{self.class.name} #{players.join(' ')}>"
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
# Check equality with another Sides object
|
|
345
|
+
#
|
|
346
|
+
# @param other [Object] object to compare
|
|
347
|
+
# @return [Boolean] true if equal
|
|
348
|
+
def ==(other)
|
|
349
|
+
return false unless other.is_a?(self.class)
|
|
350
|
+
|
|
351
|
+
@first == other.first && @second == other.second
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
alias eql? ==
|
|
355
|
+
|
|
356
|
+
# Hash code for use in collections
|
|
357
|
+
#
|
|
358
|
+
# @return [Integer] hash code
|
|
359
|
+
def hash
|
|
360
|
+
[@first, @second].hash
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
# Check if a specific side is present
|
|
364
|
+
#
|
|
365
|
+
# @param side [Symbol, String] :first or :second
|
|
366
|
+
# @return [Boolean] true if that player has data
|
|
367
|
+
#
|
|
368
|
+
# @example
|
|
369
|
+
# sides.has_player?(:first) # => true
|
|
370
|
+
# sides.has_player?("second") # => false
|
|
371
|
+
def has_player?(side)
|
|
372
|
+
player = player(side)
|
|
373
|
+
player && !player.empty?
|
|
374
|
+
end
|
|
375
|
+
|
|
376
|
+
# Get time control description
|
|
377
|
+
#
|
|
378
|
+
# @return [String, nil] human-readable time control or nil
|
|
379
|
+
#
|
|
380
|
+
# @example
|
|
381
|
+
# sides.time_control_description
|
|
382
|
+
# # => "5+3 blitz (both players)"
|
|
383
|
+
# # => "Classical 90+30 (symmetric)"
|
|
384
|
+
# # => "Unlimited time"
|
|
385
|
+
# # => "Mixed: first has 5+3, second unlimited"
|
|
386
|
+
def time_control_description
|
|
387
|
+
if unlimited_game?
|
|
388
|
+
"Unlimited time"
|
|
389
|
+
elsif mixed_time_control?
|
|
390
|
+
first_tc = describe_periods(@first.periods)
|
|
391
|
+
second_tc = describe_periods(@second.periods)
|
|
392
|
+
"Mixed: first #{first_tc}, second #{second_tc}"
|
|
393
|
+
elsif symmetric_time_control?
|
|
394
|
+
tc = describe_periods(@first.periods)
|
|
395
|
+
"#{tc} (symmetric)"
|
|
396
|
+
else
|
|
397
|
+
first_tc = describe_periods(@first.periods)
|
|
398
|
+
second_tc = describe_periods(@second.periods)
|
|
399
|
+
"First: #{first_tc}, Second: #{second_tc}"
|
|
400
|
+
end
|
|
401
|
+
end
|
|
402
|
+
|
|
403
|
+
private
|
|
404
|
+
|
|
405
|
+
# Describe time control periods in human-readable format
|
|
406
|
+
#
|
|
407
|
+
# @param periods [Array<Hash>, nil] period array
|
|
408
|
+
# @return [String] description
|
|
409
|
+
def describe_periods(periods)
|
|
410
|
+
return "unlimited" if periods.empty?
|
|
411
|
+
|
|
412
|
+
if periods.length == 1 && periods[0][:moves].nil?
|
|
413
|
+
period = periods[0]
|
|
414
|
+
time_min = period[:time] / 60
|
|
415
|
+
inc = period[:inc]
|
|
416
|
+
|
|
417
|
+
if inc > 0
|
|
418
|
+
"#{time_min}+#{inc}"
|
|
419
|
+
else
|
|
420
|
+
"#{time_min} min"
|
|
421
|
+
end
|
|
422
|
+
elsif periods.any? { |p| p[:moves] == 1 }
|
|
423
|
+
"Byōyomi"
|
|
424
|
+
elsif periods.any? { |p| p[:moves] && p[:moves] > 1 }
|
|
425
|
+
"Canadian"
|
|
426
|
+
else
|
|
427
|
+
"Classical #{periods.length} periods"
|
|
428
|
+
end
|
|
429
|
+
end
|
|
93
430
|
end
|
|
94
431
|
end
|
|
95
432
|
end
|