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.
- checksums.yaml +4 -4
- data/README.md +175 -6
- data/lib/sashite/pcn/game.rb +290 -39
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: eea8750505e46e5ec350db1cf84dd81d6d18a33fc8ec5da341864145b372eaf0
|
|
4
|
+
data.tar.gz: 7da291665e2546395a2e2de2333971aa0c5dbb48c583f29b96be2da4ceb245d6
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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
|
|
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.
|
|
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)
|
data/lib/sashite/pcn/game.rb
CHANGED
|
@@ -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
|
|
17
|
-
# All instances are immutable -
|
|
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
|
|
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
|
|
299
|
+
move&.first
|
|
239
300
|
end
|
|
240
301
|
|
|
241
|
-
# Get
|
|
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
|
|
311
|
+
move&.last
|
|
251
312
|
end
|
|
252
313
|
|
|
253
|
-
#
|
|
314
|
+
# Calculate total time spent by first player
|
|
254
315
|
#
|
|
255
|
-
# @return [Float] sum of seconds
|
|
316
|
+
# @return [Float] sum of seconds at even indices
|
|
256
317
|
#
|
|
257
318
|
# @example
|
|
258
|
-
# game.first_player_time # => 125.
|
|
319
|
+
# game.first_player_time # => 125.7
|
|
259
320
|
def first_player_time
|
|
260
|
-
@moves.each_with_index
|
|
261
|
-
|
|
262
|
-
|
|
321
|
+
@moves.each_with_index.sum do |move, index|
|
|
322
|
+
index.even? ? move.last : 0.0
|
|
323
|
+
end
|
|
263
324
|
end
|
|
264
325
|
|
|
265
|
-
#
|
|
326
|
+
# Calculate total time spent by second player
|
|
266
327
|
#
|
|
267
|
-
# @return [Float] sum of seconds
|
|
328
|
+
# @return [Float] sum of seconds at odd indices
|
|
268
329
|
#
|
|
269
330
|
# @example
|
|
270
|
-
# game.second_player_time # => 132.
|
|
331
|
+
# game.second_player_time # => 132.3
|
|
271
332
|
def second_player_time
|
|
272
|
-
@moves.each_with_index
|
|
273
|
-
|
|
274
|
-
|
|
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
|
|
342
|
+
# Get event from metadata
|
|
282
343
|
#
|
|
283
|
-
# @return [String, nil]
|
|
344
|
+
# @return [String, nil] event name or nil
|
|
284
345
|
#
|
|
285
346
|
# @example
|
|
286
|
-
# game.
|
|
287
|
-
def
|
|
288
|
-
@meta[:
|
|
347
|
+
# game.event # => "World Championship"
|
|
348
|
+
def event
|
|
349
|
+
@meta[:event]
|
|
289
350
|
end
|
|
290
351
|
|
|
291
|
-
# Get
|
|
352
|
+
# Get round from metadata
|
|
292
353
|
#
|
|
293
|
-
# @return [
|
|
354
|
+
# @return [Integer, nil] round number or nil
|
|
294
355
|
#
|
|
295
356
|
# @example
|
|
296
|
-
# game.
|
|
297
|
-
def
|
|
298
|
-
@meta[:
|
|
357
|
+
# game.round # => 5
|
|
358
|
+
def round
|
|
359
|
+
@meta[:round]
|
|
299
360
|
end
|
|
300
361
|
|
|
301
|
-
# Get
|
|
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 # => "
|
|
367
|
+
# game.location # => "Dubai, UAE"
|
|
307
368
|
def location
|
|
308
369
|
@meta[:location]
|
|
309
370
|
end
|
|
310
371
|
|
|
311
|
-
# Get
|
|
372
|
+
# Get started_at from metadata
|
|
312
373
|
#
|
|
313
|
-
# @return [
|
|
374
|
+
# @return [String, nil] ISO 8601 datetime or nil
|
|
314
375
|
#
|
|
315
376
|
# @example
|
|
316
|
-
# game.
|
|
317
|
-
def
|
|
318
|
-
@meta[:
|
|
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
|
+
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.
|
|
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
|