sashite-pcn 0.2.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 +431 -349
- data/lib/sashite/pcn/game/meta.rb +239 -0
- data/lib/sashite/pcn/game/sides/player.rb +311 -0
- data/lib/sashite/pcn/game/sides.rb +433 -0
- data/lib/sashite/pcn/game.rb +371 -325
- data/lib/sashite/pcn.rb +35 -45
- metadata +22 -9
- data/lib/sashite/pcn/error.rb +0 -38
- data/lib/sashite/pcn/meta.rb +0 -275
- data/lib/sashite/pcn/player.rb +0 -186
- data/lib/sashite/pcn/sides.rb +0 -194
|
@@ -0,0 +1,433 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "sides/player"
|
|
4
|
+
|
|
5
|
+
module Sashite
|
|
6
|
+
module Pcn
|
|
7
|
+
class Game
|
|
8
|
+
# Represents player information for both sides of a game
|
|
9
|
+
#
|
|
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.
|
|
13
|
+
#
|
|
14
|
+
# @example With both players and time control
|
|
15
|
+
# sides = Sides.new(
|
|
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
|
+
# }
|
|
46
|
+
# )
|
|
47
|
+
#
|
|
48
|
+
# @example With only first player
|
|
49
|
+
# sides = Sides.new(
|
|
50
|
+
# first: { name: "Player 1", style: "CHESS" }
|
|
51
|
+
# )
|
|
52
|
+
#
|
|
53
|
+
# @example Empty sides (no player information)
|
|
54
|
+
# sides = Sides.new # Both players default to empty
|
|
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
|
+
|
|
60
|
+
# Create a new Sides instance
|
|
61
|
+
#
|
|
62
|
+
# @param first [Hash] first player information (defaults to {})
|
|
63
|
+
# @param second [Hash] second player information (defaults to {})
|
|
64
|
+
# @raise [ArgumentError] if parameters are not hashes
|
|
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
|
+
|
|
69
|
+
@first = Player.new(**first.transform_keys(&:to_sym))
|
|
70
|
+
@second = Player.new(**second.transform_keys(&:to_sym))
|
|
71
|
+
|
|
72
|
+
freeze
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Get first player information
|
|
76
|
+
#
|
|
77
|
+
# @return [Player] first player (may be empty)
|
|
78
|
+
#
|
|
79
|
+
# @example
|
|
80
|
+
# player = sides.first
|
|
81
|
+
# player.name # => "Carlsen"
|
|
82
|
+
# player.periods # => [{ time: 5400, moves: 40, inc: 0 }, ...]
|
|
83
|
+
def first
|
|
84
|
+
@first
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Get second player information
|
|
88
|
+
#
|
|
89
|
+
# @return [Player] second player (may be empty)
|
|
90
|
+
#
|
|
91
|
+
# @example
|
|
92
|
+
# player = sides.second
|
|
93
|
+
# player.name # => "Nakamura"
|
|
94
|
+
# player.elo # => 2794
|
|
95
|
+
def second
|
|
96
|
+
@second
|
|
97
|
+
end
|
|
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
|
+
|
|
130
|
+
# Check if no player information is present
|
|
131
|
+
#
|
|
132
|
+
# @return [Boolean] true if both players are empty
|
|
133
|
+
#
|
|
134
|
+
# @example
|
|
135
|
+
# sides.empty? # => true
|
|
136
|
+
def empty?
|
|
137
|
+
@first.empty? && @second.empty?
|
|
138
|
+
end
|
|
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
|
+
|
|
295
|
+
# Convert to hash representation
|
|
296
|
+
#
|
|
297
|
+
# Returns a hash containing only non-empty player objects.
|
|
298
|
+
# If both players are empty, returns an empty hash.
|
|
299
|
+
#
|
|
300
|
+
# @return [Hash] hash with :first and/or :second keys, or empty hash
|
|
301
|
+
#
|
|
302
|
+
# @example With both players
|
|
303
|
+
# sides.to_h
|
|
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
|
+
# # }
|
|
318
|
+
#
|
|
319
|
+
# @example With only first player
|
|
320
|
+
# sides.to_h
|
|
321
|
+
# # => { first: { name: "Alice" } }
|
|
322
|
+
#
|
|
323
|
+
# @example With no players
|
|
324
|
+
# sides.to_h
|
|
325
|
+
# # => {}
|
|
326
|
+
def to_h
|
|
327
|
+
result = {}
|
|
328
|
+
result[:first] = @first.to_h unless @first.empty?
|
|
329
|
+
result[:second] = @second.to_h unless @second.empty?
|
|
330
|
+
result
|
|
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
|
|
430
|
+
end
|
|
431
|
+
end
|
|
432
|
+
end
|
|
433
|
+
end
|