sashite-pcn 0.5.0 → 0.6.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.
Files changed (3) hide show
  1. checksums.yaml +4 -4
  2. data/lib/sashite/pcn/game.rb +168 -41
  3. metadata +2 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1e856d0d80b2560d24cfd77f6359b655d4b5d67f52ad320e04504f79cab49536
4
- data.tar.gz: e539e626bd827a49071f2c80cc9095aa13abf4965433e8f14245b4461308009a
3
+ metadata.gz: eea8750505e46e5ec350db1cf84dd81d6d18a33fc8ec5da341864145b372eaf0
4
+ data.tar.gz: 7da291665e2546395a2e2de2333971aa0c5dbb48c583f29b96be2da4ceb245d6
5
5
  SHA512:
6
- metadata.gz: abdfa7ddc44437089a0a8a3d7e2a959692f33f00760a273b1df37a86d8c125fb16ef684da67a7bf382f0a67b598e5083cbb569a6de967b40b9527dd9b6d47c4d
7
- data.tar.gz: ce3dc76261263878b55a13995784074c5c1c3b4ef04a213c47b406e8faad7406a745bb232bab09a72a86cb22c8a5fdbebc10f09e8f697d3622e60451fffa1f6c
6
+ metadata.gz: 4068049f9c08386d10ff597ca25b51c5e7056ad8e6ff0fef4c40a89058158ae74acb615a50095f389e32dc8c27469e5744eb29d3791fe2c19a46d3c45acd2065
7
+ data.tar.gz: 378368a1e8cfbcb3718c63aae21a65b69e03e49d46997ef14b8eec180ec64233e0dacfdfe31c19440711c01b73ce5e4056ee873a7b5787d89368be9cde6559d9
@@ -13,8 +13,9 @@ module Sashite
13
13
  # Represents a complete game record in PCN (Portable Chess Notation) format.
14
14
  #
15
15
  # A game consists of an initial position (setup), optional move sequence with time tracking,
16
- # optional game status, optional draw offer tracking, optional metadata, and optional player
17
- # information with time control. All instances are immutable - transformations return new instances.
16
+ # optional game status, optional draw offer tracking, optional winner declaration, optional
17
+ # metadata, and optional player information with time control. All instances are immutable -
18
+ # transformations return new instances.
18
19
  #
19
20
  # All parameters are validated at initialization time. An instance of Game
20
21
  # cannot be created with invalid data.
@@ -63,6 +64,14 @@ module Sashite
63
64
  # draw_offered_by: "first",
64
65
  # status: "in_progress"
65
66
  # )
67
+ #
68
+ # @example Game with winner
69
+ # game = Game.new(
70
+ # setup: "+rnbq+kbn+r/+p+p+p+p+p+p+p+p/8/8/8/8/+P+P+P+P+P+P+P+P/+RNBQ+KBN+R / C/c",
71
+ # moves: [["e2-e4", 8.0], ["e7-e5", 12.0]],
72
+ # status: "resignation",
73
+ # winner: "first"
74
+ # )
66
75
  class Game
67
76
  # Error messages
68
77
  ERROR_MISSING_SETUP = "setup is required"
@@ -73,6 +82,7 @@ module Sashite
73
82
  ERROR_INVALID_META = "meta must be a hash"
74
83
  ERROR_INVALID_SIDES = "sides must be a hash"
75
84
  ERROR_INVALID_DRAW_OFFERED_BY = "draw_offered_by must be nil, 'first', or 'second'"
85
+ ERROR_INVALID_WINNER = "winner must be nil, 'first', 'second', or 'none'"
76
86
 
77
87
  # Status constants
78
88
  STATUS_IN_PROGRESS = "in_progress"
@@ -80,16 +90,20 @@ module Sashite
80
90
  # Valid draw_offered_by values
81
91
  VALID_DRAW_OFFERED_BY = [nil, "first", "second"].freeze
82
92
 
93
+ # Valid winner values
94
+ VALID_WINNER = [nil, "first", "second", "none"].freeze
95
+
83
96
  # Create a new game instance
84
97
  #
85
98
  # @param setup [String] initial position in FEEN format (required)
86
99
  # @param moves [Array<Array>] sequence of [PAN, seconds] tuples (optional, defaults to [])
87
100
  # @param status [String, nil] game status in CGSN format (optional)
88
101
  # @param draw_offered_by [String, nil] draw offer indicator: nil, "first", or "second" (optional)
102
+ # @param winner [String, nil] competitive outcome: nil, "first", "second", or "none" (optional)
89
103
  # @param meta [Hash] game metadata (optional)
90
104
  # @param sides [Hash] player information with time control (optional)
91
105
  # @raise [ArgumentError] if required fields are missing or invalid
92
- def initialize(setup:, moves: [], status: nil, draw_offered_by: nil, meta: {}, sides: {})
106
+ def initialize(setup:, moves: [], status: nil, draw_offered_by: nil, winner: nil, meta: {}, sides: {})
93
107
  # Validate and parse setup (required)
94
108
  raise ::ArgumentError, ERROR_MISSING_SETUP if setup.nil?
95
109
  @setup = ::Sashite::Feen.parse(setup)
@@ -105,6 +119,10 @@ module Sashite
105
119
  validate_draw_offered_by(draw_offered_by)
106
120
  @draw_offered_by = draw_offered_by
107
121
 
122
+ # Validate winner (optional)
123
+ validate_winner(winner)
124
+ @winner = winner
125
+
108
126
  # Validate meta (optional)
109
127
  raise ::ArgumentError, ERROR_INVALID_META unless meta.is_a?(::Hash)
110
128
  @meta = Meta.new(**meta.transform_keys(&:to_sym))
@@ -181,6 +199,19 @@ module Sashite
181
199
  @draw_offered_by
182
200
  end
183
201
 
202
+ # Get competitive outcome
203
+ #
204
+ # @return [String, nil] "first", "second", "none", or nil
205
+ #
206
+ # @example
207
+ # game.winner # => "first"
208
+ # game.winner # => "second"
209
+ # game.winner # => "none"
210
+ # game.winner # => nil
211
+ def winner
212
+ @winner
213
+ end
214
+
184
215
  # ========================================================================
185
216
  # Player Access
186
217
  # ========================================================================
@@ -250,101 +281,112 @@ module Sashite
250
281
  moves: new_moves,
251
282
  status: @status&.to_s,
252
283
  draw_offered_by: @draw_offered_by,
284
+ winner: @winner,
253
285
  meta: @meta.to_h,
254
286
  sides: @sides.to_h
255
287
  )
256
288
  end
257
289
 
258
- # Get the PAN notation from a move
290
+ # Get PAN notation at specified index
259
291
  #
260
- # @param index [Integer] move index
292
+ # @param index [Integer] move index (0-based)
261
293
  # @return [String, nil] PAN notation or nil if out of bounds
262
294
  #
263
295
  # @example
264
296
  # game.pan_at(0) # => "e2-e4"
265
297
  def pan_at(index)
266
298
  move = @moves[index]
267
- move ? move[0] : nil
299
+ move&.first
268
300
  end
269
301
 
270
- # Get the seconds spent on a move
302
+ # Get seconds at specified index
271
303
  #
272
- # @param index [Integer] move index
304
+ # @param index [Integer] move index (0-based)
273
305
  # @return [Float, nil] seconds or nil if out of bounds
274
306
  #
275
307
  # @example
276
308
  # game.seconds_at(0) # => 2.5
277
309
  def seconds_at(index)
278
310
  move = @moves[index]
279
- move ? move[1] : nil
311
+ move&.last
280
312
  end
281
313
 
282
- # Get total time spent by first player
314
+ # Calculate total time spent by first player
283
315
  #
284
- # @return [Float] sum of seconds for moves at even indices
316
+ # @return [Float] sum of seconds at even indices
285
317
  #
286
318
  # @example
287
- # game.first_player_time # => 125.3
319
+ # game.first_player_time # => 125.7
288
320
  def first_player_time
289
- @moves.each_with_index
290
- .select { |_, i| i.even? }
291
- .sum { |move, _| move[1] }
321
+ @moves.each_with_index.sum do |move, index|
322
+ index.even? ? move.last : 0.0
323
+ end
292
324
  end
293
325
 
294
- # Get total time spent by second player
326
+ # Calculate total time spent by second player
295
327
  #
296
- # @return [Float] sum of seconds for moves at odd indices
328
+ # @return [Float] sum of seconds at odd indices
297
329
  #
298
330
  # @example
299
- # game.second_player_time # => 132.7
331
+ # game.second_player_time # => 132.3
300
332
  def second_player_time
301
- @moves.each_with_index
302
- .select { |_, i| i.odd? }
303
- .sum { |move, _| move[1] }
333
+ @moves.each_with_index.sum do |move, index|
334
+ index.odd? ? move.last : 0.0
335
+ end
304
336
  end
305
337
 
306
338
  # ========================================================================
307
339
  # Metadata Shortcuts
308
340
  # ========================================================================
309
341
 
310
- # Get game start timestamp
342
+ # Get event from metadata
311
343
  #
312
- # @return [String, nil] start timestamp in ISO 8601 format
344
+ # @return [String, nil] event name or nil
313
345
  #
314
346
  # @example
315
- # game.started_at # => "2025-01-27T14:00:00Z"
316
- def started_at
317
- @meta[:started_at]
347
+ # game.event # => "World Championship"
348
+ def event
349
+ @meta[:event]
318
350
  end
319
351
 
320
- # Get event name
352
+ # Get round from metadata
321
353
  #
322
- # @return [String, nil] event name
354
+ # @return [Integer, nil] round number or nil
323
355
  #
324
356
  # @example
325
- # game.event # => "World Championship"
326
- def event
327
- @meta[:event]
357
+ # game.round # => 5
358
+ def round
359
+ @meta[:round]
328
360
  end
329
361
 
330
- # Get event location
362
+ # Get location from metadata
331
363
  #
332
- # @return [String, nil] location
364
+ # @return [String, nil] location or nil
333
365
  #
334
366
  # @example
335
- # game.location # => "London"
367
+ # game.location # => "Dubai, UAE"
336
368
  def location
337
369
  @meta[:location]
338
370
  end
339
371
 
340
- # Get round number
372
+ # Get started_at from metadata
341
373
  #
342
- # @return [Integer, nil] round number
374
+ # @return [String, nil] ISO 8601 datetime or nil
343
375
  #
344
376
  # @example
345
- # game.round # => 5
346
- def round
347
- @meta[:round]
377
+ # game.started_at # => "2025-01-27T14:00:00Z"
378
+ def started_at
379
+ @meta[:started_at]
380
+ end
381
+
382
+ # Get href from metadata
383
+ #
384
+ # @return [String, nil] URL or nil
385
+ #
386
+ # @example
387
+ # game.href # => "https://example.com/game/123"
388
+ def href
389
+ @meta[:href]
348
390
  end
349
391
 
350
392
  # ========================================================================
@@ -355,6 +397,7 @@ module Sashite
355
397
  #
356
398
  # @param new_status [String, nil] new status value
357
399
  # @return [Game] new game instance with updated status
400
+ # @raise [ArgumentError] if status is invalid
358
401
  #
359
402
  # @example
360
403
  # updated = game.with_status("resignation")
@@ -364,6 +407,7 @@ module Sashite
364
407
  moves: @moves,
365
408
  status: new_status,
366
409
  draw_offered_by: @draw_offered_by,
410
+ winner: @winner,
367
411
  meta: @meta.to_h,
368
412
  sides: @sides.to_h
369
413
  )
@@ -387,6 +431,37 @@ module Sashite
387
431
  moves: @moves,
388
432
  status: @status&.to_s,
389
433
  draw_offered_by: player,
434
+ winner: @winner,
435
+ meta: @meta.to_h,
436
+ sides: @sides.to_h
437
+ )
438
+ end
439
+
440
+ # Create new game with updated winner
441
+ #
442
+ # @param new_winner [String, nil] "first", "second", "none", or nil
443
+ # @return [Game] new game instance with updated winner
444
+ # @raise [ArgumentError] if winner is invalid
445
+ #
446
+ # @example
447
+ # # First player wins
448
+ # game_first_wins = game.with_winner("first")
449
+ #
450
+ # # Second player wins
451
+ # game_second_wins = game.with_winner("second")
452
+ #
453
+ # # Draw (no winner)
454
+ # game_draw = game.with_winner("none")
455
+ #
456
+ # # Clear winner
457
+ # game_in_progress = game.with_winner(nil)
458
+ def with_winner(new_winner)
459
+ self.class.new(
460
+ setup: @setup.to_s,
461
+ moves: @moves,
462
+ status: @status&.to_s,
463
+ draw_offered_by: @draw_offered_by,
464
+ winner: new_winner,
390
465
  meta: @meta.to_h,
391
466
  sides: @sides.to_h
392
467
  )
@@ -406,6 +481,7 @@ module Sashite
406
481
  moves: @moves,
407
482
  status: @status&.to_s,
408
483
  draw_offered_by: @draw_offered_by,
484
+ winner: @winner,
409
485
  meta: merged_meta,
410
486
  sides: @sides.to_h
411
487
  )
@@ -425,6 +501,7 @@ module Sashite
425
501
  moves: new_moves,
426
502
  status: @status&.to_s,
427
503
  draw_offered_by: @draw_offered_by,
504
+ winner: @winner,
428
505
  meta: @meta.to_h,
429
506
  sides: @sides.to_h
430
507
  )
@@ -469,6 +546,42 @@ module Sashite
469
546
  !@draw_offered_by.nil?
470
547
  end
471
548
 
549
+ # Check if a winner has been determined
550
+ #
551
+ # @return [Boolean] true if winner is determined (first, second, or none)
552
+ #
553
+ # @example
554
+ # game.has_winner? # => true (if winner is "first", "second", or "none")
555
+ # game.has_winner? # => false (if winner is nil)
556
+ def has_winner?
557
+ !@winner.nil?
558
+ end
559
+
560
+ # Check if the game had a decisive outcome (not a draw)
561
+ #
562
+ # @return [Boolean, nil] true if decisive (first or second won), false if draw, nil if no winner
563
+ #
564
+ # @example
565
+ # game.decisive? # => true (if winner is "first" or "second")
566
+ # game.decisive? # => false (if winner is "none")
567
+ # game.decisive? # => nil (if winner is nil)
568
+ def decisive?
569
+ return nil if @winner.nil?
570
+
571
+ @winner != "none"
572
+ end
573
+
574
+ # Check if the game ended in a draw
575
+ #
576
+ # @return [Boolean] true if winner is "none" (draw)
577
+ #
578
+ # @example
579
+ # game.drawn? # => true (if winner is "none")
580
+ # game.drawn? # => false (if winner is nil, "first", or "second")
581
+ def drawn?
582
+ @winner == "none"
583
+ end
584
+
472
585
  # ========================================================================
473
586
  # Serialization
474
587
  # ========================================================================
@@ -484,6 +597,7 @@ module Sashite
484
597
  # # "moves" => [["e2-e4", 2.5], ["e7-e5", 3.1]],
485
598
  # # "status" => "in_progress",
486
599
  # # "draw_offered_by" => "first",
600
+ # # "winner" => nil,
487
601
  # # "meta" => {...},
488
602
  # # "sides" => {...}
489
603
  # # }
@@ -496,6 +610,7 @@ module Sashite
496
610
  # Include optional fields if present
497
611
  result["status"] = @status.to_s if @status
498
612
  result["draw_offered_by"] = @draw_offered_by if @draw_offered_by
613
+ result["winner"] = @winner if @winner
499
614
  result["meta"] = @meta.to_h unless @meta.empty?
500
615
  result["sides"] = @sides.to_h unless @sides.empty?
501
616
 
@@ -516,6 +631,7 @@ module Sashite
516
631
  @moves == other.moves &&
517
632
  @status&.to_s == other.status&.to_s &&
518
633
  @draw_offered_by == other.draw_offered_by &&
634
+ @winner == other.winner &&
519
635
  @meta == other.meta &&
520
636
  @sides == other.sides
521
637
  end
@@ -527,7 +643,7 @@ module Sashite
527
643
  # @example
528
644
  # game.hash # => 123456789
529
645
  def hash
530
- [@setup.to_s, @moves, @status&.to_s, @draw_offered_by, @meta, @sides].hash
646
+ [@setup.to_s, @moves, @status&.to_s, @draw_offered_by, @winner, @meta, @sides].hash
531
647
  end
532
648
 
533
649
  # Generate debug representation
@@ -536,12 +652,13 @@ module Sashite
536
652
  #
537
653
  # @example
538
654
  # game.inspect
539
- # # => "#<Game setup=\"...\" moves=[...] status=\"in_progress\" draw_offered_by=\"first\">"
655
+ # # => "#<Game setup=\"...\" moves=[...] status=\"in_progress\" draw_offered_by=\"first\" winner=nil>"
540
656
  def inspect
541
657
  parts = ["setup=#{@setup.to_s.inspect}"]
542
658
  parts << "moves=#{@moves.inspect}"
543
659
  parts << "status=#{@status&.to_s.inspect}" if @status
544
660
  parts << "draw_offered_by=#{@draw_offered_by.inspect}" if @draw_offered_by
661
+ parts << "winner=#{@winner.inspect}" if @winner
545
662
  parts << "meta=#{@meta.inspect}" unless @meta.empty?
546
663
  parts << "sides=#{@sides.inspect}" unless @sides.empty?
547
664
 
@@ -602,6 +719,16 @@ module Sashite
602
719
 
603
720
  raise ::ArgumentError, ERROR_INVALID_DRAW_OFFERED_BY
604
721
  end
722
+
723
+ # Validate winner field
724
+ #
725
+ # @param value [String, nil] winner value to validate
726
+ # @raise [ArgumentError] if value is invalid
727
+ def validate_winner(value)
728
+ return if VALID_WINNER.include?(value)
729
+
730
+ raise ::ArgumentError, ERROR_INVALID_WINNER
731
+ end
605
732
  end
606
733
  end
607
734
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: sashite-pcn
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.0
4
+ version: 0.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Cyril Kato
@@ -113,7 +113,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
113
113
  - !ruby/object:Gem::Version
114
114
  version: '0'
115
115
  requirements: []
116
- rubygems_version: 3.6.9
116
+ rubygems_version: 3.7.2
117
117
  specification_version: 4
118
118
  summary: PCN (Portable Chess Notation) implementation for Ruby with comprehensive
119
119
  game record representation