sashite-pcn 0.4.1 → 0.5.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 +127 -3
  4. metadata +1 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d4f4d314fd4110d2ae43096e8b1763e69bfaf66bf141d20d8a85f9226fd6bf80
4
- data.tar.gz: e4dc01512ae6e3a43a691205158cb375d8ed72e06a60bc1f0a71daf447534bf6
3
+ metadata.gz: 1e856d0d80b2560d24cfd77f6359b655d4b5d67f52ad320e04504f79cab49536
4
+ data.tar.gz: e539e626bd827a49071f2c80cc9095aa13abf4965433e8f14245b4461308009a
5
5
  SHA512:
6
- metadata.gz: 5ad2af5a5074be25e88d4173fdebf180e6dfb4213e3fb1f0b85d9cc7360976433cc3e7c36d2b248cd790ce35929c354bc6726601482881a38a5769a73c9fa010
7
- data.tar.gz: 9df7d3ac32169ba3c2a3f4491b9f7870ff6ff0388005ee5e896af799d8a8d2d4f47a382c99287918356af683536a8f6e1d3aced38c0accb33ae37e2bc4898f66
6
+ metadata.gz: abdfa7ddc44437089a0a8a3d7e2a959692f33f00760a273b1df37a86d8c125fb16ef684da67a7bf382f0a67b598e5083cbb569a6de967b40b9527dd9b6d47c4d
7
+ data.tar.gz: ce3dc76261263878b55a13995784074c5c1c3b4ef04a213c47b406e8faad7406a745bb232bab09a72a86cb22c8a5fdbebc10f09e8f697d3622e60451fffa1f6c
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,8 @@ 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 metadata, and optional player
17
+ # information with time control. All instances are immutable - transformations return new instances.
18
18
  #
19
19
  # All parameters are validated at initialization time. An instance of Game
20
20
  # cannot be created with invalid data.
@@ -55,6 +55,14 @@ module Sashite
55
55
  # ],
56
56
  # status: "in_progress"
57
57
  # )
58
+ #
59
+ # @example Game with draw offer
60
+ # game = Game.new(
61
+ # 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",
62
+ # moves: [["e2-e4", 8.0], ["e7-e5", 12.0]],
63
+ # draw_offered_by: "first",
64
+ # status: "in_progress"
65
+ # )
58
66
  class Game
59
67
  # Error messages
60
68
  ERROR_MISSING_SETUP = "setup is required"
@@ -64,19 +72,24 @@ module Sashite
64
72
  ERROR_INVALID_SECONDS = "seconds must be a non-negative number"
65
73
  ERROR_INVALID_META = "meta must be a hash"
66
74
  ERROR_INVALID_SIDES = "sides must be a hash"
75
+ ERROR_INVALID_DRAW_OFFERED_BY = "draw_offered_by must be nil, 'first', or 'second'"
67
76
 
68
77
  # Status constants
69
78
  STATUS_IN_PROGRESS = "in_progress"
70
79
 
80
+ # Valid draw_offered_by values
81
+ VALID_DRAW_OFFERED_BY = [nil, "first", "second"].freeze
82
+
71
83
  # Create a new game instance
72
84
  #
73
85
  # @param setup [String] initial position in FEEN format (required)
74
86
  # @param moves [Array<Array>] sequence of [PAN, seconds] tuples (optional, defaults to [])
75
87
  # @param status [String, nil] game status in CGSN format (optional)
88
+ # @param draw_offered_by [String, nil] draw offer indicator: nil, "first", or "second" (optional)
76
89
  # @param meta [Hash] game metadata (optional)
77
90
  # @param sides [Hash] player information with time control (optional)
78
91
  # @raise [ArgumentError] if required fields are missing or invalid
79
- def initialize(setup:, moves: [], status: nil, meta: {}, sides: {})
92
+ def initialize(setup:, moves: [], status: nil, draw_offered_by: nil, meta: {}, sides: {})
80
93
  # Validate and parse setup (required)
81
94
  raise ::ArgumentError, ERROR_MISSING_SETUP if setup.nil?
82
95
  @setup = ::Sashite::Feen.parse(setup)
@@ -88,6 +101,10 @@ module Sashite
88
101
  # Validate and parse status (optional)
89
102
  @status = status.nil? ? nil : ::Sashite::Cgsn.parse(status)
90
103
 
104
+ # Validate draw_offered_by (optional)
105
+ validate_draw_offered_by(draw_offered_by)
106
+ @draw_offered_by = draw_offered_by
107
+
91
108
  # Validate meta (optional)
92
109
  raise ::ArgumentError, ERROR_INVALID_META unless meta.is_a?(::Hash)
93
110
  @meta = Meta.new(**meta.transform_keys(&:to_sym))
@@ -153,6 +170,17 @@ module Sashite
153
170
  @status
154
171
  end
155
172
 
173
+ # Get draw offer indicator
174
+ #
175
+ # @return [String, nil] "first", "second", or nil
176
+ #
177
+ # @example
178
+ # game.draw_offered_by # => "first"
179
+ # game.draw_offered_by # => nil
180
+ def draw_offered_by
181
+ @draw_offered_by
182
+ end
183
+
156
184
  # ========================================================================
157
185
  # Player Access
158
186
  # ========================================================================
@@ -221,6 +249,7 @@ module Sashite
221
249
  setup: @setup.to_s,
222
250
  moves: new_moves,
223
251
  status: @status&.to_s,
252
+ draw_offered_by: @draw_offered_by,
224
253
  meta: @meta.to_h,
225
254
  sides: @sides.to_h
226
255
  )
@@ -334,6 +363,30 @@ module Sashite
334
363
  setup: @setup.to_s,
335
364
  moves: @moves,
336
365
  status: new_status,
366
+ draw_offered_by: @draw_offered_by,
367
+ meta: @meta.to_h,
368
+ sides: @sides.to_h
369
+ )
370
+ end
371
+
372
+ # Create new game with updated draw offer
373
+ #
374
+ # @param player [String, nil] "first", "second", or nil
375
+ # @return [Game] new game instance with updated draw offer
376
+ # @raise [ArgumentError] if player is invalid
377
+ #
378
+ # @example
379
+ # # First player offers a draw
380
+ # game_with_offer = game.with_draw_offered_by("first")
381
+ #
382
+ # # Withdraw draw offer
383
+ # game_no_offer = game.with_draw_offered_by(nil)
384
+ def with_draw_offered_by(player)
385
+ self.class.new(
386
+ setup: @setup.to_s,
387
+ moves: @moves,
388
+ status: @status&.to_s,
389
+ draw_offered_by: player,
337
390
  meta: @meta.to_h,
338
391
  sides: @sides.to_h
339
392
  )
@@ -352,6 +405,7 @@ module Sashite
352
405
  setup: @setup.to_s,
353
406
  moves: @moves,
354
407
  status: @status&.to_s,
408
+ draw_offered_by: @draw_offered_by,
355
409
  meta: merged_meta,
356
410
  sides: @sides.to_h
357
411
  )
@@ -370,6 +424,7 @@ module Sashite
370
424
  setup: @setup.to_s,
371
425
  moves: new_moves,
372
426
  status: @status&.to_s,
427
+ draw_offered_by: @draw_offered_by,
373
428
  meta: @meta.to_h,
374
429
  sides: @sides.to_h
375
430
  )
@@ -403,6 +458,17 @@ module Sashite
403
458
  !in_progress?
404
459
  end
405
460
 
461
+ # Check if a draw offer is pending
462
+ #
463
+ # @return [Boolean] true if a draw offer is pending
464
+ #
465
+ # @example
466
+ # game.draw_offered? # => true (if draw_offered_by is "first" or "second")
467
+ # game.draw_offered? # => false (if draw_offered_by is nil)
468
+ def draw_offered?
469
+ !@draw_offered_by.nil?
470
+ end
471
+
406
472
  # ========================================================================
407
473
  # Serialization
408
474
  # ========================================================================
@@ -417,6 +483,7 @@ module Sashite
417
483
  # # "setup" => "...",
418
484
  # # "moves" => [["e2-e4", 2.5], ["e7-e5", 3.1]],
419
485
  # # "status" => "in_progress",
486
+ # # "draw_offered_by" => "first",
420
487
  # # "meta" => {...},
421
488
  # # "sides" => {...}
422
489
  # # }
@@ -428,12 +495,59 @@ module Sashite
428
495
 
429
496
  # Include optional fields if present
430
497
  result["status"] = @status.to_s if @status
498
+ result["draw_offered_by"] = @draw_offered_by if @draw_offered_by
431
499
  result["meta"] = @meta.to_h unless @meta.empty?
432
500
  result["sides"] = @sides.to_h unless @sides.empty?
433
501
 
434
502
  result
435
503
  end
436
504
 
505
+ # Compare with another game
506
+ #
507
+ # @param other [Object] object to compare
508
+ # @return [Boolean] true if equal
509
+ #
510
+ # @example
511
+ # game1 == game2 # => true if all attributes match
512
+ def ==(other)
513
+ return false unless other.is_a?(Game)
514
+
515
+ @setup.to_s == other.setup.to_s &&
516
+ @moves == other.moves &&
517
+ @status&.to_s == other.status&.to_s &&
518
+ @draw_offered_by == other.draw_offered_by &&
519
+ @meta == other.meta &&
520
+ @sides == other.sides
521
+ end
522
+
523
+ # Generate hash code
524
+ #
525
+ # @return [Integer] hash code for this game
526
+ #
527
+ # @example
528
+ # game.hash # => 123456789
529
+ def hash
530
+ [@setup.to_s, @moves, @status&.to_s, @draw_offered_by, @meta, @sides].hash
531
+ end
532
+
533
+ # Generate debug representation
534
+ #
535
+ # @return [String] debug string
536
+ #
537
+ # @example
538
+ # game.inspect
539
+ # # => "#<Game setup=\"...\" moves=[...] status=\"in_progress\" draw_offered_by=\"first\">"
540
+ def inspect
541
+ parts = ["setup=#{@setup.to_s.inspect}"]
542
+ parts << "moves=#{@moves.inspect}"
543
+ parts << "status=#{@status&.to_s.inspect}" if @status
544
+ parts << "draw_offered_by=#{@draw_offered_by.inspect}" if @draw_offered_by
545
+ parts << "meta=#{@meta.inspect}" unless @meta.empty?
546
+ parts << "sides=#{@sides.inspect}" unless @sides.empty?
547
+
548
+ "#<#{self.class.name} #{parts.join(' ')}>"
549
+ end
550
+
437
551
  private
438
552
 
439
553
  # Validate and parse moves array
@@ -478,6 +592,16 @@ module Sashite
478
592
  # Return the move tuple with seconds as float
479
593
  [pan_notation, seconds.to_f].freeze
480
594
  end
595
+
596
+ # Validate draw_offered_by field
597
+ #
598
+ # @param value [String, nil] draw offer value to validate
599
+ # @raise [ArgumentError] if value is invalid
600
+ def validate_draw_offered_by(value)
601
+ return if VALID_DRAW_OFFERED_BY.include?(value)
602
+
603
+ raise ::ArgumentError, ERROR_INVALID_DRAW_OFFERED_BY
604
+ end
481
605
  end
482
606
  end
483
607
  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.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Cyril Kato