sashite-pcn 0.4.1 → 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 (4) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +175 -6
  3. data/lib/sashite/pcn/game.rb +290 -39
  4. metadata +2 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d4f4d314fd4110d2ae43096e8b1763e69bfaf66bf141d20d8a85f9226fd6bf80
4
- data.tar.gz: e4dc01512ae6e3a43a691205158cb375d8ed72e06a60bc1f0a71daf447534bf6
3
+ metadata.gz: eea8750505e46e5ec350db1cf84dd81d6d18a33fc8ec5da341864145b372eaf0
4
+ data.tar.gz: 7da291665e2546395a2e2de2333971aa0c5dbb48c583f29b96be2da4ceb245d6
5
5
  SHA512:
6
- metadata.gz: 5ad2af5a5074be25e88d4173fdebf180e6dfb4213e3fb1f0b85d9cc7360976433cc3e7c36d2b248cd790ce35929c354bc6726601482881a38a5769a73c9fa010
7
- data.tar.gz: 9df7d3ac32169ba3c2a3f4491b9f7870ff6ff0388005ee5e896af799d8a8d2d4f47a382c99287918356af683536a8f6e1d3aced38c0accb33ae37e2bc4898f66
6
+ metadata.gz: 4068049f9c08386d10ff597ca25b51c5e7056ad8e6ff0fef4c40a89058158ae74acb615a50095f389e32dc8c27469e5744eb29d3791fe2c19a46d3c45acd2065
7
+ data.tar.gz: 378368a1e8cfbcb3718c63aae21a65b69e03e49d46997ef14b8eec180ec64233e0dacfdfe31c19440711c01b73ce5e4056ee873a7b5787d89368be9cde6559d9
data/README.md CHANGED
@@ -15,6 +15,7 @@
15
15
  - [API Documentation](#api-documentation)
16
16
  - [Format Specifications](#format-specifications)
17
17
  - [Time Control Examples](#time-control-examples)
18
+ - [Draw Offers](#draw-offers)
18
19
  - [Error Handling](#error-handling)
19
20
  - [Complete Examples](#complete-examples)
20
21
  - [JSON Interoperability](#json-interoperability)
@@ -24,6 +25,7 @@
24
25
  PCN (Portable Chess Notation) is a comprehensive, JSON-based format for representing complete chess game records across variants. This Ruby implementation provides:
25
26
 
26
27
  - **Complete game records** with positions, moves, time tracking, and metadata
28
+ - **Draw offer tracking** for recording draw proposals between players
27
29
  - **Time control support** for Fischer, Classical, Byōyomi, Canadian, and more
28
30
  - **Rule-agnostic design** supporting all abstract strategy board games
29
31
  - **Immutable objects** with functional transformations
@@ -80,11 +82,16 @@ game.status # => CGSN status object
80
82
  # Transform immutably
81
83
  new_game = game.add_move(["g1-f3", 1.8])
82
84
  final_game = new_game.with_status("checkmate")
85
+
86
+ # Handle draw offers
87
+ game_with_offer = game.with_draw_offered_by("first")
88
+ game.draw_offered? # => true
89
+ game.draw_offered_by # => "first"
83
90
  ```
84
91
 
85
92
  ## API Documentation
86
93
 
87
- For complete API documentation, see [API.md](https://github.com/sashite/pcn.rb/blob/v0.4.1/API.md).
94
+ For complete API documentation, see [API Reference](https://rubydoc.info/github/sashite/pcn.rb/main/file/API.md).
88
95
 
89
96
  The API documentation includes:
90
97
  - All classes and methods
@@ -93,6 +100,7 @@ The API documentation includes:
93
100
  - Code examples for every method
94
101
  - Common usage patterns
95
102
  - Time control formats
103
+ - Draw offer handling
96
104
  - Error handling
97
105
 
98
106
  ## Format Specifications
@@ -102,7 +110,7 @@ The API documentation includes:
102
110
  ```ruby
103
111
  # Standard chess starting position
104
112
  "+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"
105
- # └─ board ─────────────────────────────────────────────────────┘ └┘ └─┘
113
+ # └─ board ──────────────────────────────────────────────┘ └┘ └─┘
106
114
  # turn styles
107
115
 
108
116
  # Empty board
@@ -150,8 +158,10 @@ The API documentation includes:
150
158
  # Explicit only (must be declared)
151
159
  "resignation" # Player resigned
152
160
  "time_limit" # Time expired
153
- "agreement" # Mutual agreement
161
+ "agreement" # Mutual agreement (draw)
154
162
  "illegal_move" # Invalid move played
163
+ "repetition" # Draw by repetition
164
+ "move_limit" # Move limit reached
155
165
  ```
156
166
 
157
167
  ### SNN (Styles)
@@ -232,6 +242,74 @@ periods: [] # Empty array
232
242
  periods: nil # Or omit entirely
233
243
  ```
234
244
 
245
+ ## Draw Offers
246
+
247
+ PCN supports tracking draw offers between players using the `draw_offered_by` field.
248
+
249
+ ### Basic Usage
250
+
251
+ ```ruby
252
+ # Offer a draw
253
+ game = game.with_draw_offered_by("first") # First player offers
254
+
255
+ # Check if draw offered
256
+ game.draw_offered? # => true
257
+ game.draw_offered_by # => "first"
258
+
259
+ # Accept the draw
260
+ game = game.with_status("agreement")
261
+
262
+ # Decline/withdraw draw offer
263
+ game = game.with_draw_offered_by(nil)
264
+ ```
265
+
266
+ ### Draw Offer Values
267
+
268
+ ```ruby
269
+ nil # No draw offer pending (default)
270
+ "first" # First player has offered a draw
271
+ "second" # Second player has offered a draw
272
+ ```
273
+
274
+ ### Example: Draw Offer During Game
275
+
276
+ ```ruby
277
+ # Game in progress with draw offer
278
+ game = Sashite::Pcn.parse({
279
+ "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",
280
+ "moves" => [
281
+ ["e2-e4", 8.0],
282
+ ["e7-e5", 12.0],
283
+ ["g1-f3", 15.0]
284
+ ],
285
+ "draw_offered_by" => "first",
286
+ "status" => "in_progress"
287
+ })
288
+
289
+ # First player has offered a draw after move 3
290
+ puts "Draw offered by: #{game.draw_offered_by}" # => "first"
291
+ ```
292
+
293
+ ### Example: Accepted Draw
294
+
295
+ ```ruby
296
+ # Draw accepted
297
+ game = Sashite::Pcn.parse({
298
+ "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",
299
+ "moves" => [
300
+ ["e2-e4", 15.0],
301
+ ["e7-e5", 18.0],
302
+ ["g1-f3", 22.0],
303
+ ["b8-c6", 12.0]
304
+ ],
305
+ "draw_offered_by" => "first",
306
+ "status" => "agreement"
307
+ })
308
+
309
+ # First player offered, second player accepted
310
+ puts "Game result: Draw by agreement"
311
+ ```
312
+
235
313
  ## Error Handling
236
314
 
237
315
  ```ruby
@@ -256,6 +334,16 @@ rescue ArgumentError => e
256
334
  puts e.message # => "Each move must be [PAN string, seconds float] tuple"
257
335
  end
258
336
 
337
+ # Draw offer validation
338
+ begin
339
+ Sashite::Pcn::Game.new(
340
+ setup: "8/8/8/8/8/8/8/8 / U/u",
341
+ draw_offered_by: "third" # Invalid: must be nil, "first", or "second"
342
+ )
343
+ rescue ArgumentError => e
344
+ puts e.message # => "draw_offered_by must be nil, 'first', or 'second'"
345
+ end
346
+
259
347
  # Metadata validation
260
348
  begin
261
349
  Sashite::Pcn::Game.new(
@@ -275,6 +363,7 @@ begin
275
363
  ]
276
364
  }
277
365
  }
366
+ Sashite::Pcn::Game.new(setup: "8/8/8/8/8/8/8/8 / U/u", sides: sides)
278
367
  rescue ArgumentError => e
279
368
  puts e.message # => "time must be a non-negative integer (>= 0)"
280
369
  end
@@ -353,14 +442,58 @@ puts "Moves played: #{game.move_count}"
353
442
  puts "White time: #{game.first_player_time}s"
354
443
  puts "Black time: #{game.second_player_time}s"
355
444
 
445
+ # Offer a draw
446
+ game = game.with_draw_offered_by("first")
447
+
356
448
  # Finish game
357
449
  if some_condition
358
450
  game = game.with_status("checkmate")
359
- elsif another_condition
451
+ elsif draw_accepted?
452
+ game = game.with_status("agreement")
453
+ else
360
454
  game = game.with_status("resignation")
361
455
  end
362
456
  ```
363
457
 
458
+ ### Game with Draw Offer
459
+
460
+ ```ruby
461
+ # Complete game with draw offer and acceptance
462
+ game = Sashite::Pcn::Game.new(
463
+ meta: {
464
+ event: "Club Match",
465
+ round: 5,
466
+ started_at: "2025-01-27T14:00:00Z"
467
+ },
468
+ sides: {
469
+ first: {
470
+ name: "Player A",
471
+ elo: 2200,
472
+ style: "CHESS"
473
+ },
474
+ second: {
475
+ name: "Player B",
476
+ elo: 2190,
477
+ style: "chess"
478
+ }
479
+ },
480
+ 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",
481
+ moves: [
482
+ ["e2-e4", 15.0],
483
+ ["e7-e5", 18.0],
484
+ ["g1-f3", 22.0],
485
+ ["b8-c6", 12.0],
486
+ ["d2-d4", 31.0],
487
+ ["e5+d4", 25.0]
488
+ ],
489
+ draw_offered_by: "first",
490
+ status: "agreement"
491
+ )
492
+
493
+ puts "Result: Draw by agreement"
494
+ puts "Initiated by: #{game.draw_offered_by}"
495
+ ```
496
+
364
497
  ### Complex Tournament Game
365
498
 
366
499
  ```ruby
@@ -437,7 +570,41 @@ puts "Winner: #{game.status == 'resignation' ? 'First player (White)' : 'Unknown
437
570
  puts "Total moves: #{game.move_count}"
438
571
 
439
572
  # Export to JSON file
440
- File.write("game.json", JSON.generate(game.to_h))
573
+ File.write("game.json", JSON.pretty_generate(game.to_h))
574
+ ```
575
+
576
+ ### Draw Offer Scenario
577
+
578
+ ```ruby
579
+ # Game progressing with draw offer
580
+ game = Sashite::Pcn::Game.new(
581
+ 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"
582
+ )
583
+
584
+ # Play several moves
585
+ game = game.add_move(["e2-e4", 8.0])
586
+ game = game.add_move(["e7-e5", 12.0])
587
+ game = game.add_move(["g1-f3", 15.0])
588
+ game = game.add_move(["b8-c6", 5.0])
589
+
590
+ # First player offers a draw
591
+ game = game.with_draw_offered_by("first")
592
+
593
+ # Check the offer
594
+ if game.draw_offered?
595
+ puts "Draw offered by: #{game.draw_offered_by}"
596
+
597
+ # Second player can accept
598
+ if player_accepts_draw?
599
+ game = game.with_status("agreement")
600
+ puts "Draw accepted!"
601
+ else
602
+ # Or decline and continue
603
+ game = game.with_draw_offered_by(nil)
604
+ game = game.add_move(["f1-c4", 9.0])
605
+ puts "Draw declined, game continues"
606
+ end
607
+ end
441
608
  ```
442
609
 
443
610
  ## JSON Interoperability
@@ -511,6 +678,7 @@ record.save!
511
678
  record = GameRecord.find(id)
512
679
  game = record.game
513
680
  puts game.move_count
681
+ puts "Draw offered: #{game.draw_offered?}"
514
682
  ```
515
683
 
516
684
  ## Properties
@@ -520,13 +688,14 @@ puts game.move_count
520
688
  - **Type-safe**: Strong type checking throughout
521
689
  - **Rule-agnostic**: Independent of specific game rules
522
690
  - **JSON-native**: Direct serialization to/from JSON
523
- - **Comprehensive**: Complete game information including time tracking
691
+ - **Comprehensive**: Complete game information including time tracking and draw offers
524
692
  - **Extensible**: Custom metadata and player fields supported
525
693
 
526
694
  ## Documentation
527
695
 
528
696
  - [Official PCN Specification v1.0.0](https://sashite.dev/specs/pcn/1.0.0/)
529
697
  - [PCN Examples](https://sashite.dev/specs/pcn/1.0.0/examples/)
698
+ - [Draw Offer Examples](https://sashite.dev/specs/pcn/1.0.0/examples/draw-offers/)
530
699
  - [API Documentation](https://rubydoc.info/github/sashite/pcn.rb/main)
531
700
  - [PAN Specification](https://sashite.dev/specs/pan/) (moves)
532
701
  - [FEEN Specification](https://sashite.dev/specs/feen/) (positions)
@@ -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 metadata, and optional player information with time control.
17
- # 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.
@@ -55,6 +56,22 @@ module Sashite
55
56
  # ],
56
57
  # status: "in_progress"
57
58
  # )
59
+ #
60
+ # @example Game with draw offer
61
+ # game = Game.new(
62
+ # 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",
63
+ # moves: [["e2-e4", 8.0], ["e7-e5", 12.0]],
64
+ # draw_offered_by: "first",
65
+ # status: "in_progress"
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
+ # )
58
75
  class Game
59
76
  # Error messages
60
77
  ERROR_MISSING_SETUP = "setup is required"
@@ -64,19 +81,29 @@ module Sashite
64
81
  ERROR_INVALID_SECONDS = "seconds must be a non-negative number"
65
82
  ERROR_INVALID_META = "meta must be a hash"
66
83
  ERROR_INVALID_SIDES = "sides must be a hash"
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'"
67
86
 
68
87
  # Status constants
69
88
  STATUS_IN_PROGRESS = "in_progress"
70
89
 
90
+ # Valid draw_offered_by values
91
+ VALID_DRAW_OFFERED_BY = [nil, "first", "second"].freeze
92
+
93
+ # Valid winner values
94
+ VALID_WINNER = [nil, "first", "second", "none"].freeze
95
+
71
96
  # Create a new game instance
72
97
  #
73
98
  # @param setup [String] initial position in FEEN format (required)
74
99
  # @param moves [Array<Array>] sequence of [PAN, seconds] tuples (optional, defaults to [])
75
100
  # @param status [String, nil] game status in CGSN format (optional)
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)
76
103
  # @param meta [Hash] game metadata (optional)
77
104
  # @param sides [Hash] player information with time control (optional)
78
105
  # @raise [ArgumentError] if required fields are missing or invalid
79
- def initialize(setup:, moves: [], status: nil, meta: {}, sides: {})
106
+ def initialize(setup:, moves: [], status: nil, draw_offered_by: nil, winner: nil, meta: {}, sides: {})
80
107
  # Validate and parse setup (required)
81
108
  raise ::ArgumentError, ERROR_MISSING_SETUP if setup.nil?
82
109
  @setup = ::Sashite::Feen.parse(setup)
@@ -88,6 +115,14 @@ module Sashite
88
115
  # Validate and parse status (optional)
89
116
  @status = status.nil? ? nil : ::Sashite::Cgsn.parse(status)
90
117
 
118
+ # Validate draw_offered_by (optional)
119
+ validate_draw_offered_by(draw_offered_by)
120
+ @draw_offered_by = draw_offered_by
121
+
122
+ # Validate winner (optional)
123
+ validate_winner(winner)
124
+ @winner = winner
125
+
91
126
  # Validate meta (optional)
92
127
  raise ::ArgumentError, ERROR_INVALID_META unless meta.is_a?(::Hash)
93
128
  @meta = Meta.new(**meta.transform_keys(&:to_sym))
@@ -153,6 +188,30 @@ module Sashite
153
188
  @status
154
189
  end
155
190
 
191
+ # Get draw offer indicator
192
+ #
193
+ # @return [String, nil] "first", "second", or nil
194
+ #
195
+ # @example
196
+ # game.draw_offered_by # => "first"
197
+ # game.draw_offered_by # => nil
198
+ def draw_offered_by
199
+ @draw_offered_by
200
+ end
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
+
156
215
  # ========================================================================
157
216
  # Player Access
158
217
  # ========================================================================
@@ -221,101 +280,113 @@ module Sashite
221
280
  setup: @setup.to_s,
222
281
  moves: new_moves,
223
282
  status: @status&.to_s,
283
+ draw_offered_by: @draw_offered_by,
284
+ winner: @winner,
224
285
  meta: @meta.to_h,
225
286
  sides: @sides.to_h
226
287
  )
227
288
  end
228
289
 
229
- # Get the PAN notation from a move
290
+ # Get PAN notation at specified index
230
291
  #
231
- # @param index [Integer] move index
292
+ # @param index [Integer] move index (0-based)
232
293
  # @return [String, nil] PAN notation or nil if out of bounds
233
294
  #
234
295
  # @example
235
296
  # game.pan_at(0) # => "e2-e4"
236
297
  def pan_at(index)
237
298
  move = @moves[index]
238
- move ? move[0] : nil
299
+ move&.first
239
300
  end
240
301
 
241
- # Get the seconds spent on a move
302
+ # Get seconds at specified index
242
303
  #
243
- # @param index [Integer] move index
304
+ # @param index [Integer] move index (0-based)
244
305
  # @return [Float, nil] seconds or nil if out of bounds
245
306
  #
246
307
  # @example
247
308
  # game.seconds_at(0) # => 2.5
248
309
  def seconds_at(index)
249
310
  move = @moves[index]
250
- move ? move[1] : nil
311
+ move&.last
251
312
  end
252
313
 
253
- # Get total time spent by first player
314
+ # Calculate total time spent by first player
254
315
  #
255
- # @return [Float] sum of seconds for moves at even indices
316
+ # @return [Float] sum of seconds at even indices
256
317
  #
257
318
  # @example
258
- # game.first_player_time # => 125.3
319
+ # game.first_player_time # => 125.7
259
320
  def first_player_time
260
- @moves.each_with_index
261
- .select { |_, i| i.even? }
262
- .sum { |move, _| move[1] }
321
+ @moves.each_with_index.sum do |move, index|
322
+ index.even? ? move.last : 0.0
323
+ end
263
324
  end
264
325
 
265
- # Get total time spent by second player
326
+ # Calculate total time spent by second player
266
327
  #
267
- # @return [Float] sum of seconds for moves at odd indices
328
+ # @return [Float] sum of seconds at odd indices
268
329
  #
269
330
  # @example
270
- # game.second_player_time # => 132.7
331
+ # game.second_player_time # => 132.3
271
332
  def second_player_time
272
- @moves.each_with_index
273
- .select { |_, i| i.odd? }
274
- .sum { |move, _| move[1] }
333
+ @moves.each_with_index.sum do |move, index|
334
+ index.odd? ? move.last : 0.0
335
+ end
275
336
  end
276
337
 
277
338
  # ========================================================================
278
339
  # Metadata Shortcuts
279
340
  # ========================================================================
280
341
 
281
- # Get game start timestamp
342
+ # Get event from metadata
282
343
  #
283
- # @return [String, nil] start timestamp in ISO 8601 format
344
+ # @return [String, nil] event name or nil
284
345
  #
285
346
  # @example
286
- # game.started_at # => "2025-01-27T14:00:00Z"
287
- def started_at
288
- @meta[:started_at]
347
+ # game.event # => "World Championship"
348
+ def event
349
+ @meta[:event]
289
350
  end
290
351
 
291
- # Get event name
352
+ # Get round from metadata
292
353
  #
293
- # @return [String, nil] event name
354
+ # @return [Integer, nil] round number or nil
294
355
  #
295
356
  # @example
296
- # game.event # => "World Championship"
297
- def event
298
- @meta[:event]
357
+ # game.round # => 5
358
+ def round
359
+ @meta[:round]
299
360
  end
300
361
 
301
- # Get event location
362
+ # Get location from metadata
302
363
  #
303
- # @return [String, nil] location
364
+ # @return [String, nil] location or nil
304
365
  #
305
366
  # @example
306
- # game.location # => "London"
367
+ # game.location # => "Dubai, UAE"
307
368
  def location
308
369
  @meta[:location]
309
370
  end
310
371
 
311
- # Get round number
372
+ # Get started_at from metadata
312
373
  #
313
- # @return [Integer, nil] round number
374
+ # @return [String, nil] ISO 8601 datetime or nil
314
375
  #
315
376
  # @example
316
- # game.round # => 5
317
- def round
318
- @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]
319
390
  end
320
391
 
321
392
  # ========================================================================
@@ -326,6 +397,7 @@ module Sashite
326
397
  #
327
398
  # @param new_status [String, nil] new status value
328
399
  # @return [Game] new game instance with updated status
400
+ # @raise [ArgumentError] if status is invalid
329
401
  #
330
402
  # @example
331
403
  # updated = game.with_status("resignation")
@@ -334,6 +406,62 @@ module Sashite
334
406
  setup: @setup.to_s,
335
407
  moves: @moves,
336
408
  status: new_status,
409
+ draw_offered_by: @draw_offered_by,
410
+ winner: @winner,
411
+ meta: @meta.to_h,
412
+ sides: @sides.to_h
413
+ )
414
+ end
415
+
416
+ # Create new game with updated draw offer
417
+ #
418
+ # @param player [String, nil] "first", "second", or nil
419
+ # @return [Game] new game instance with updated draw offer
420
+ # @raise [ArgumentError] if player is invalid
421
+ #
422
+ # @example
423
+ # # First player offers a draw
424
+ # game_with_offer = game.with_draw_offered_by("first")
425
+ #
426
+ # # Withdraw draw offer
427
+ # game_no_offer = game.with_draw_offered_by(nil)
428
+ def with_draw_offered_by(player)
429
+ self.class.new(
430
+ setup: @setup.to_s,
431
+ moves: @moves,
432
+ status: @status&.to_s,
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,
337
465
  meta: @meta.to_h,
338
466
  sides: @sides.to_h
339
467
  )
@@ -352,6 +480,8 @@ module Sashite
352
480
  setup: @setup.to_s,
353
481
  moves: @moves,
354
482
  status: @status&.to_s,
483
+ draw_offered_by: @draw_offered_by,
484
+ winner: @winner,
355
485
  meta: merged_meta,
356
486
  sides: @sides.to_h
357
487
  )
@@ -370,6 +500,8 @@ module Sashite
370
500
  setup: @setup.to_s,
371
501
  moves: new_moves,
372
502
  status: @status&.to_s,
503
+ draw_offered_by: @draw_offered_by,
504
+ winner: @winner,
373
505
  meta: @meta.to_h,
374
506
  sides: @sides.to_h
375
507
  )
@@ -403,6 +535,53 @@ module Sashite
403
535
  !in_progress?
404
536
  end
405
537
 
538
+ # Check if a draw offer is pending
539
+ #
540
+ # @return [Boolean] true if a draw offer is pending
541
+ #
542
+ # @example
543
+ # game.draw_offered? # => true (if draw_offered_by is "first" or "second")
544
+ # game.draw_offered? # => false (if draw_offered_by is nil)
545
+ def draw_offered?
546
+ !@draw_offered_by.nil?
547
+ end
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
+
406
585
  # ========================================================================
407
586
  # Serialization
408
587
  # ========================================================================
@@ -417,6 +596,8 @@ module Sashite
417
596
  # # "setup" => "...",
418
597
  # # "moves" => [["e2-e4", 2.5], ["e7-e5", 3.1]],
419
598
  # # "status" => "in_progress",
599
+ # # "draw_offered_by" => "first",
600
+ # # "winner" => nil,
420
601
  # # "meta" => {...},
421
602
  # # "sides" => {...}
422
603
  # # }
@@ -428,12 +609,62 @@ module Sashite
428
609
 
429
610
  # Include optional fields if present
430
611
  result["status"] = @status.to_s if @status
612
+ result["draw_offered_by"] = @draw_offered_by if @draw_offered_by
613
+ result["winner"] = @winner if @winner
431
614
  result["meta"] = @meta.to_h unless @meta.empty?
432
615
  result["sides"] = @sides.to_h unless @sides.empty?
433
616
 
434
617
  result
435
618
  end
436
619
 
620
+ # Compare with another game
621
+ #
622
+ # @param other [Object] object to compare
623
+ # @return [Boolean] true if equal
624
+ #
625
+ # @example
626
+ # game1 == game2 # => true if all attributes match
627
+ def ==(other)
628
+ return false unless other.is_a?(Game)
629
+
630
+ @setup.to_s == other.setup.to_s &&
631
+ @moves == other.moves &&
632
+ @status&.to_s == other.status&.to_s &&
633
+ @draw_offered_by == other.draw_offered_by &&
634
+ @winner == other.winner &&
635
+ @meta == other.meta &&
636
+ @sides == other.sides
637
+ end
638
+
639
+ # Generate hash code
640
+ #
641
+ # @return [Integer] hash code for this game
642
+ #
643
+ # @example
644
+ # game.hash # => 123456789
645
+ def hash
646
+ [@setup.to_s, @moves, @status&.to_s, @draw_offered_by, @winner, @meta, @sides].hash
647
+ end
648
+
649
+ # Generate debug representation
650
+ #
651
+ # @return [String] debug string
652
+ #
653
+ # @example
654
+ # game.inspect
655
+ # # => "#<Game setup=\"...\" moves=[...] status=\"in_progress\" draw_offered_by=\"first\" winner=nil>"
656
+ def inspect
657
+ parts = ["setup=#{@setup.to_s.inspect}"]
658
+ parts << "moves=#{@moves.inspect}"
659
+ parts << "status=#{@status&.to_s.inspect}" if @status
660
+ parts << "draw_offered_by=#{@draw_offered_by.inspect}" if @draw_offered_by
661
+ parts << "winner=#{@winner.inspect}" if @winner
662
+ parts << "meta=#{@meta.inspect}" unless @meta.empty?
663
+ parts << "sides=#{@sides.inspect}" unless @sides.empty?
664
+
665
+ "#<#{self.class.name} #{parts.join(' ')}>"
666
+ end
667
+
437
668
  private
438
669
 
439
670
  # Validate and parse moves array
@@ -478,6 +709,26 @@ module Sashite
478
709
  # Return the move tuple with seconds as float
479
710
  [pan_notation, seconds.to_f].freeze
480
711
  end
712
+
713
+ # Validate draw_offered_by field
714
+ #
715
+ # @param value [String, nil] draw offer value to validate
716
+ # @raise [ArgumentError] if value is invalid
717
+ def validate_draw_offered_by(value)
718
+ return if VALID_DRAW_OFFERED_BY.include?(value)
719
+
720
+ raise ::ArgumentError, ERROR_INVALID_DRAW_OFFERED_BY
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
481
732
  end
482
733
  end
483
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.4.1
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