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.
- checksums.yaml +4 -4
- data/lib/sashite/pcn/game.rb +168 -41
- 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/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 draw offer tracking, optional
|
|
17
|
-
# information with time control. 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.
|
|
@@ -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
|
|
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
|
|
299
|
+
move&.first
|
|
268
300
|
end
|
|
269
301
|
|
|
270
|
-
# Get
|
|
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
|
|
311
|
+
move&.last
|
|
280
312
|
end
|
|
281
313
|
|
|
282
|
-
#
|
|
314
|
+
# Calculate total time spent by first player
|
|
283
315
|
#
|
|
284
|
-
# @return [Float] sum of seconds
|
|
316
|
+
# @return [Float] sum of seconds at even indices
|
|
285
317
|
#
|
|
286
318
|
# @example
|
|
287
|
-
# game.first_player_time # => 125.
|
|
319
|
+
# game.first_player_time # => 125.7
|
|
288
320
|
def first_player_time
|
|
289
|
-
@moves.each_with_index
|
|
290
|
-
|
|
291
|
-
|
|
321
|
+
@moves.each_with_index.sum do |move, index|
|
|
322
|
+
index.even? ? move.last : 0.0
|
|
323
|
+
end
|
|
292
324
|
end
|
|
293
325
|
|
|
294
|
-
#
|
|
326
|
+
# Calculate total time spent by second player
|
|
295
327
|
#
|
|
296
|
-
# @return [Float] sum of seconds
|
|
328
|
+
# @return [Float] sum of seconds at odd indices
|
|
297
329
|
#
|
|
298
330
|
# @example
|
|
299
|
-
# game.second_player_time # => 132.
|
|
331
|
+
# game.second_player_time # => 132.3
|
|
300
332
|
def second_player_time
|
|
301
|
-
@moves.each_with_index
|
|
302
|
-
|
|
303
|
-
|
|
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
|
|
342
|
+
# Get event from metadata
|
|
311
343
|
#
|
|
312
|
-
# @return [String, nil]
|
|
344
|
+
# @return [String, nil] event name or nil
|
|
313
345
|
#
|
|
314
346
|
# @example
|
|
315
|
-
# game.
|
|
316
|
-
def
|
|
317
|
-
@meta[:
|
|
347
|
+
# game.event # => "World Championship"
|
|
348
|
+
def event
|
|
349
|
+
@meta[:event]
|
|
318
350
|
end
|
|
319
351
|
|
|
320
|
-
# Get
|
|
352
|
+
# Get round from metadata
|
|
321
353
|
#
|
|
322
|
-
# @return [
|
|
354
|
+
# @return [Integer, nil] round number or nil
|
|
323
355
|
#
|
|
324
356
|
# @example
|
|
325
|
-
# game.
|
|
326
|
-
def
|
|
327
|
-
@meta[:
|
|
357
|
+
# game.round # => 5
|
|
358
|
+
def round
|
|
359
|
+
@meta[:round]
|
|
328
360
|
end
|
|
329
361
|
|
|
330
|
-
# Get
|
|
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 # => "
|
|
367
|
+
# game.location # => "Dubai, UAE"
|
|
336
368
|
def location
|
|
337
369
|
@meta[:location]
|
|
338
370
|
end
|
|
339
371
|
|
|
340
|
-
# Get
|
|
372
|
+
# Get started_at from metadata
|
|
341
373
|
#
|
|
342
|
-
# @return [
|
|
374
|
+
# @return [String, nil] ISO 8601 datetime or nil
|
|
343
375
|
#
|
|
344
376
|
# @example
|
|
345
|
-
# game.
|
|
346
|
-
def
|
|
347
|
-
@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]
|
|
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.
|
|
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
|