sashite-pcn 0.3.0 → 0.4.1

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.
@@ -7,13 +7,42 @@ module Sashite
7
7
  class Game
8
8
  # Represents player information for both sides of a game
9
9
  #
10
- # Both players are optional and default to empty player objects.
11
- # An empty Sides object (no player information) is valid.
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: { name: "Carlsen", elo: 2830, style: "CHESS" },
16
- # second: { name: "Nakamura", elo: 2794, style: "chess" }
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 # => #<Sashite::Pcn::Game::Sides::Player ...>
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 # => #<Sashite::Pcn::Game::Sides::Player ...>
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
- # # => { first: { name: "Carlsen", elo: 2830, style: "CHESS" },
78
- # # second: { name: "Nakamura", elo: 2794, style: "chess" } }
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>] 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