sashite-pcn 0.3.0 → 0.4.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 +488 -279
- data/lib/sashite/pcn/game/meta.rb +94 -25
- data/lib/sashite/pcn/game/sides/player.rb +192 -10
- data/lib/sashite/pcn/game/sides.rb +347 -10
- data/lib/sashite/pcn/game.rb +157 -42
- data/lib/sashite/pcn.rb +2 -2
- metadata +5 -5
data/lib/sashite/pcn/game.rb
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require "sashite/cgsn"
|
|
4
4
|
require "sashite/feen"
|
|
5
|
-
require "sashite/
|
|
5
|
+
require "sashite/pan"
|
|
6
6
|
require "sashite/snn"
|
|
7
7
|
|
|
8
8
|
require_relative "game/meta"
|
|
@@ -12,31 +12,56 @@ module Sashite
|
|
|
12
12
|
module Pcn
|
|
13
13
|
# Represents a complete game record in PCN (Portable Chess Notation) format.
|
|
14
14
|
#
|
|
15
|
-
# A game consists of an initial position (setup), optional move sequence,
|
|
16
|
-
# optional game status, optional metadata, and optional player information.
|
|
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
17
|
# 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.
|
|
21
21
|
#
|
|
22
22
|
# @example Minimal game
|
|
23
|
-
# game = Game.new(setup: "
|
|
23
|
+
# game = Game.new(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")
|
|
24
24
|
#
|
|
25
|
-
# @example Complete game
|
|
25
|
+
# @example Complete game with time tracking
|
|
26
26
|
# game = Game.new(
|
|
27
|
-
# meta: {
|
|
27
|
+
# meta: {
|
|
28
|
+
# event: "World Championship",
|
|
29
|
+
# started_at: "2025-01-27T14:00:00Z"
|
|
30
|
+
# },
|
|
28
31
|
# sides: {
|
|
29
|
-
# first: {
|
|
30
|
-
#
|
|
32
|
+
# first: {
|
|
33
|
+
# name: "Carlsen",
|
|
34
|
+
# elo: 2830,
|
|
35
|
+
# style: "CHESS",
|
|
36
|
+
# periods: [
|
|
37
|
+
# { time: 5400, moves: 40, inc: 0 },
|
|
38
|
+
# { time: 1800, moves: nil, inc: 30 }
|
|
39
|
+
# ]
|
|
40
|
+
# },
|
|
41
|
+
# second: {
|
|
42
|
+
# name: "Nakamura",
|
|
43
|
+
# elo: 2794,
|
|
44
|
+
# style: "chess",
|
|
45
|
+
# periods: [
|
|
46
|
+
# { time: 5400, moves: 40, inc: 0 },
|
|
47
|
+
# { time: 1800, moves: nil, inc: 30 }
|
|
48
|
+
# ]
|
|
49
|
+
# }
|
|
31
50
|
# },
|
|
32
|
-
# setup: "
|
|
33
|
-
# moves: [
|
|
51
|
+
# 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",
|
|
52
|
+
# moves: [
|
|
53
|
+
# ["e2-e4", 2.5],
|
|
54
|
+
# ["c7-c5", 3.1]
|
|
55
|
+
# ],
|
|
34
56
|
# status: "in_progress"
|
|
35
57
|
# )
|
|
36
58
|
class Game
|
|
37
59
|
# Error messages
|
|
38
60
|
ERROR_MISSING_SETUP = "setup is required"
|
|
39
61
|
ERROR_INVALID_MOVES = "moves must be an array"
|
|
62
|
+
ERROR_INVALID_MOVE_FORMAT = "each move must be [PAN string, seconds float] tuple"
|
|
63
|
+
ERROR_INVALID_PAN = "invalid PAN notation in move"
|
|
64
|
+
ERROR_INVALID_SECONDS = "seconds must be a non-negative number"
|
|
40
65
|
ERROR_INVALID_META = "meta must be a hash"
|
|
41
66
|
ERROR_INVALID_SIDES = "sides must be a hash"
|
|
42
67
|
|
|
@@ -46,10 +71,10 @@ module Sashite
|
|
|
46
71
|
# Create a new game instance
|
|
47
72
|
#
|
|
48
73
|
# @param setup [String] initial position in FEEN format (required)
|
|
49
|
-
# @param moves [Array<Array>] sequence of
|
|
74
|
+
# @param moves [Array<Array>] sequence of [PAN, seconds] tuples (optional, defaults to [])
|
|
50
75
|
# @param status [String, nil] game status in CGSN format (optional)
|
|
51
76
|
# @param meta [Hash] game metadata (optional)
|
|
52
|
-
# @param sides [Hash] player information (optional)
|
|
77
|
+
# @param sides [Hash] player information with time control (optional)
|
|
53
78
|
# @raise [ArgumentError] if required fields are missing or invalid
|
|
54
79
|
def initialize(setup:, moves: [], status: nil, meta: {}, sides: {})
|
|
55
80
|
# Validate and parse setup (required)
|
|
@@ -58,7 +83,7 @@ module Sashite
|
|
|
58
83
|
|
|
59
84
|
# Validate and parse moves (optional, defaults to [])
|
|
60
85
|
raise ::ArgumentError, ERROR_INVALID_MOVES unless moves.is_a?(::Array)
|
|
61
|
-
@moves = moves
|
|
86
|
+
@moves = validate_and_parse_moves(moves).freeze
|
|
62
87
|
|
|
63
88
|
# Validate and parse status (optional)
|
|
64
89
|
@status = status.nil? ? nil : ::Sashite::Cgsn.parse(status)
|
|
@@ -108,12 +133,12 @@ module Sashite
|
|
|
108
133
|
@sides
|
|
109
134
|
end
|
|
110
135
|
|
|
111
|
-
# Get move sequence
|
|
136
|
+
# Get move sequence with time tracking
|
|
112
137
|
#
|
|
113
|
-
# @return [Array<
|
|
138
|
+
# @return [Array<Array>] frozen array of [PAN, seconds] tuples
|
|
114
139
|
#
|
|
115
140
|
# @example
|
|
116
|
-
# game.moves # => [
|
|
141
|
+
# game.moves # => [["e2-e4", 2.5], ["e7-e5", 3.1]]
|
|
117
142
|
def moves
|
|
118
143
|
@moves
|
|
119
144
|
end
|
|
@@ -137,7 +162,8 @@ module Sashite
|
|
|
137
162
|
# @return [Hash, nil] first player data or nil if not defined
|
|
138
163
|
#
|
|
139
164
|
# @example
|
|
140
|
-
# game.first_player
|
|
165
|
+
# game.first_player
|
|
166
|
+
# # => { name: "Carlsen", elo: 2830, style: "CHESS", periods: [...] }
|
|
141
167
|
def first_player
|
|
142
168
|
@sides.first
|
|
143
169
|
end
|
|
@@ -147,7 +173,8 @@ module Sashite
|
|
|
147
173
|
# @return [Hash, nil] second player data or nil if not defined
|
|
148
174
|
#
|
|
149
175
|
# @example
|
|
150
|
-
# game.second_player
|
|
176
|
+
# game.second_player
|
|
177
|
+
# # => { name: "Nakamura", elo: 2794, style: "chess", periods: [...] }
|
|
151
178
|
def second_player
|
|
152
179
|
@sides.second
|
|
153
180
|
end
|
|
@@ -159,10 +186,10 @@ module Sashite
|
|
|
159
186
|
# Get move at specified index
|
|
160
187
|
#
|
|
161
188
|
# @param index [Integer] move index (0-based)
|
|
162
|
-
# @return [
|
|
189
|
+
# @return [Array, nil] [PAN, seconds] tuple at index or nil if out of bounds
|
|
163
190
|
#
|
|
164
191
|
# @example
|
|
165
|
-
# game.move_at(0) # =>
|
|
192
|
+
# game.move_at(0) # => ["e2-e4", 2.5]
|
|
166
193
|
def move_at(index)
|
|
167
194
|
@moves[index]
|
|
168
195
|
end
|
|
@@ -179,13 +206,17 @@ module Sashite
|
|
|
179
206
|
|
|
180
207
|
# Add a move to the game
|
|
181
208
|
#
|
|
182
|
-
# @param move [Array]
|
|
209
|
+
# @param move [Array] [PAN, seconds] tuple
|
|
183
210
|
# @return [Game] new game instance with added move
|
|
211
|
+
# @raise [ArgumentError] if move format is invalid
|
|
184
212
|
#
|
|
185
213
|
# @example
|
|
186
|
-
# new_game = game.add_move(["g1",
|
|
214
|
+
# new_game = game.add_move(["g1-f3", 1.8])
|
|
187
215
|
def add_move(move)
|
|
188
|
-
|
|
216
|
+
# Validate the new move
|
|
217
|
+
validate_move_tuple(move)
|
|
218
|
+
|
|
219
|
+
new_moves = @moves + [move]
|
|
189
220
|
self.class.new(
|
|
190
221
|
setup: @setup.to_s,
|
|
191
222
|
moves: new_moves,
|
|
@@ -195,28 +226,66 @@ module Sashite
|
|
|
195
226
|
)
|
|
196
227
|
end
|
|
197
228
|
|
|
198
|
-
#
|
|
199
|
-
#
|
|
200
|
-
#
|
|
229
|
+
# Get the PAN notation from a move
|
|
230
|
+
#
|
|
231
|
+
# @param index [Integer] move index
|
|
232
|
+
# @return [String, nil] PAN notation or nil if out of bounds
|
|
233
|
+
#
|
|
234
|
+
# @example
|
|
235
|
+
# game.pan_at(0) # => "e2-e4"
|
|
236
|
+
def pan_at(index)
|
|
237
|
+
move = @moves[index]
|
|
238
|
+
move ? move[0] : nil
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
# Get the seconds spent on a move
|
|
242
|
+
#
|
|
243
|
+
# @param index [Integer] move index
|
|
244
|
+
# @return [Float, nil] seconds or nil if out of bounds
|
|
245
|
+
#
|
|
246
|
+
# @example
|
|
247
|
+
# game.seconds_at(0) # => 2.5
|
|
248
|
+
def seconds_at(index)
|
|
249
|
+
move = @moves[index]
|
|
250
|
+
move ? move[1] : nil
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
# Get total time spent by first player
|
|
254
|
+
#
|
|
255
|
+
# @return [Float] sum of seconds for moves at even indices
|
|
256
|
+
#
|
|
257
|
+
# @example
|
|
258
|
+
# game.first_player_time # => 125.3
|
|
259
|
+
def first_player_time
|
|
260
|
+
@moves.each_with_index
|
|
261
|
+
.select { |_, i| i.even? }
|
|
262
|
+
.sum { |move, _| move[1] }
|
|
263
|
+
end
|
|
201
264
|
|
|
202
|
-
# Get
|
|
265
|
+
# Get total time spent by second player
|
|
203
266
|
#
|
|
204
|
-
# @return [
|
|
267
|
+
# @return [Float] sum of seconds for moves at odd indices
|
|
205
268
|
#
|
|
206
269
|
# @example
|
|
207
|
-
# game.
|
|
208
|
-
def
|
|
209
|
-
@
|
|
270
|
+
# game.second_player_time # => 132.7
|
|
271
|
+
def second_player_time
|
|
272
|
+
@moves.each_with_index
|
|
273
|
+
.select { |_, i| i.odd? }
|
|
274
|
+
.sum { |move, _| move[1] }
|
|
210
275
|
end
|
|
211
276
|
|
|
212
|
-
#
|
|
277
|
+
# ========================================================================
|
|
278
|
+
# Metadata Shortcuts
|
|
279
|
+
# ========================================================================
|
|
280
|
+
|
|
281
|
+
# Get game start timestamp
|
|
213
282
|
#
|
|
214
|
-
# @return [String, nil]
|
|
283
|
+
# @return [String, nil] start timestamp in ISO 8601 format
|
|
215
284
|
#
|
|
216
285
|
# @example
|
|
217
|
-
# game.
|
|
218
|
-
def
|
|
219
|
-
@meta[:
|
|
286
|
+
# game.started_at # => "2025-01-27T14:00:00Z"
|
|
287
|
+
def started_at
|
|
288
|
+
@meta[:started_at]
|
|
220
289
|
end
|
|
221
290
|
|
|
222
291
|
# Get event name
|
|
@@ -263,7 +332,7 @@ module Sashite
|
|
|
263
332
|
def with_status(new_status)
|
|
264
333
|
self.class.new(
|
|
265
334
|
setup: @setup.to_s,
|
|
266
|
-
moves: @moves
|
|
335
|
+
moves: @moves,
|
|
267
336
|
status: new_status,
|
|
268
337
|
meta: @meta.to_h,
|
|
269
338
|
sides: @sides.to_h
|
|
@@ -281,7 +350,7 @@ module Sashite
|
|
|
281
350
|
merged_meta = @meta.to_h.merge(new_meta)
|
|
282
351
|
self.class.new(
|
|
283
352
|
setup: @setup.to_s,
|
|
284
|
-
moves: @moves
|
|
353
|
+
moves: @moves,
|
|
285
354
|
status: @status&.to_s,
|
|
286
355
|
meta: merged_meta,
|
|
287
356
|
sides: @sides.to_h
|
|
@@ -290,11 +359,12 @@ module Sashite
|
|
|
290
359
|
|
|
291
360
|
# Create new game with specified move sequence
|
|
292
361
|
#
|
|
293
|
-
# @param new_moves [Array<Array>] new move sequence
|
|
362
|
+
# @param new_moves [Array<Array>] new move sequence of [PAN, seconds] tuples
|
|
294
363
|
# @return [Game] new game instance with new moves
|
|
364
|
+
# @raise [ArgumentError] if move format is invalid
|
|
295
365
|
#
|
|
296
366
|
# @example
|
|
297
|
-
# updated = game.with_moves([["e2",
|
|
367
|
+
# updated = game.with_moves([["e2-e4", 2.0], ["e7-e5", 3.0]])
|
|
298
368
|
def with_moves(new_moves)
|
|
299
369
|
self.class.new(
|
|
300
370
|
setup: @setup.to_s,
|
|
@@ -345,7 +415,7 @@ module Sashite
|
|
|
345
415
|
# game.to_h
|
|
346
416
|
# # => {
|
|
347
417
|
# # "setup" => "...",
|
|
348
|
-
# # "moves" => [[
|
|
418
|
+
# # "moves" => [["e2-e4", 2.5], ["e7-e5", 3.1]],
|
|
349
419
|
# # "status" => "in_progress",
|
|
350
420
|
# # "meta" => {...},
|
|
351
421
|
# # "sides" => {...}
|
|
@@ -354,7 +424,7 @@ module Sashite
|
|
|
354
424
|
result = { "setup" => @setup.to_s }
|
|
355
425
|
|
|
356
426
|
# Always include moves array (even if empty)
|
|
357
|
-
result["moves"] = @moves
|
|
427
|
+
result["moves"] = @moves
|
|
358
428
|
|
|
359
429
|
# Include optional fields if present
|
|
360
430
|
result["status"] = @status.to_s if @status
|
|
@@ -363,6 +433,51 @@ module Sashite
|
|
|
363
433
|
|
|
364
434
|
result
|
|
365
435
|
end
|
|
436
|
+
|
|
437
|
+
private
|
|
438
|
+
|
|
439
|
+
# Validate and parse moves array
|
|
440
|
+
#
|
|
441
|
+
# @param moves [Array] array of move tuples
|
|
442
|
+
# @return [Array<Array>] validated moves
|
|
443
|
+
# @raise [ArgumentError] if any move is invalid
|
|
444
|
+
def validate_and_parse_moves(moves)
|
|
445
|
+
moves.map.with_index do |move, index|
|
|
446
|
+
validate_move_tuple(move, index)
|
|
447
|
+
end
|
|
448
|
+
end
|
|
449
|
+
|
|
450
|
+
# Validate a single move tuple
|
|
451
|
+
#
|
|
452
|
+
# @param move [Array] [PAN, seconds] tuple
|
|
453
|
+
# @param index [Integer, nil] optional index for error messages
|
|
454
|
+
# @raise [ArgumentError] if move format is invalid
|
|
455
|
+
def validate_move_tuple(move, index = nil)
|
|
456
|
+
position = index ? " at index #{index}" : ""
|
|
457
|
+
|
|
458
|
+
# Check it's an array with exactly 2 elements
|
|
459
|
+
raise ::ArgumentError, "#{ERROR_INVALID_MOVE_FORMAT}#{position}" unless move.is_a?(::Array) && move.length == 2
|
|
460
|
+
|
|
461
|
+
pan_notation, seconds = move
|
|
462
|
+
|
|
463
|
+
# Validate PAN notation
|
|
464
|
+
unless pan_notation.is_a?(::String)
|
|
465
|
+
raise ::ArgumentError, "#{ERROR_INVALID_PAN}#{position}: PAN must be a string"
|
|
466
|
+
end
|
|
467
|
+
|
|
468
|
+
# Parse PAN to validate format (this will raise if invalid)
|
|
469
|
+
begin
|
|
470
|
+
::Sashite::Pan.parse(pan_notation)
|
|
471
|
+
rescue StandardError => e
|
|
472
|
+
raise ::ArgumentError, "#{ERROR_INVALID_PAN}#{position}: #{e.message}"
|
|
473
|
+
end
|
|
474
|
+
|
|
475
|
+
# Validate seconds (must be a non-negative number)
|
|
476
|
+
raise ::ArgumentError, "#{ERROR_INVALID_SECONDS}#{position}" unless seconds.is_a?(::Numeric) && seconds >= 0
|
|
477
|
+
|
|
478
|
+
# Return the move tuple with seconds as float
|
|
479
|
+
[pan_notation, seconds.to_f].freeze
|
|
480
|
+
end
|
|
366
481
|
end
|
|
367
482
|
end
|
|
368
483
|
end
|
data/lib/sashite/pcn.rb
CHANGED
|
@@ -19,7 +19,7 @@ module Sashite
|
|
|
19
19
|
#
|
|
20
20
|
# @example Parse minimal PCN
|
|
21
21
|
# game = Sashite::Pcn.parse({
|
|
22
|
-
# "setup" => "
|
|
22
|
+
# "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"
|
|
23
23
|
# })
|
|
24
24
|
#
|
|
25
25
|
# @example Parse complete game
|
|
@@ -29,7 +29,7 @@ module Sashite
|
|
|
29
29
|
# "first" => { "name" => "Carlsen", "elo" => 2830, "style" => "CHESS" },
|
|
30
30
|
# "second" => { "name" => "Nakamura", "elo" => 2794, "style" => "chess" }
|
|
31
31
|
# },
|
|
32
|
-
# "setup" => "
|
|
32
|
+
# "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",
|
|
33
33
|
# "moves" => [["e2", "e4"], ["e7", "e5"]],
|
|
34
34
|
# "status" => "in_progress"
|
|
35
35
|
# })
|
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.4.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Cyril Kato
|
|
@@ -38,19 +38,19 @@ dependencies:
|
|
|
38
38
|
- !ruby/object:Gem::Version
|
|
39
39
|
version: '0.3'
|
|
40
40
|
- !ruby/object:Gem::Dependency
|
|
41
|
-
name: sashite-
|
|
41
|
+
name: sashite-pan
|
|
42
42
|
requirement: !ruby/object:Gem::Requirement
|
|
43
43
|
requirements:
|
|
44
44
|
- - "~>"
|
|
45
45
|
- !ruby/object:Gem::Version
|
|
46
|
-
version: '
|
|
46
|
+
version: '4.0'
|
|
47
47
|
type: :runtime
|
|
48
48
|
prerelease: false
|
|
49
49
|
version_requirements: !ruby/object:Gem::Requirement
|
|
50
50
|
requirements:
|
|
51
51
|
- - "~>"
|
|
52
52
|
- !ruby/object:Gem::Version
|
|
53
|
-
version: '
|
|
53
|
+
version: '4.0'
|
|
54
54
|
- !ruby/object:Gem::Dependency
|
|
55
55
|
name: sashite-snn
|
|
56
56
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -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.1
|
|
117
117
|
specification_version: 4
|
|
118
118
|
summary: PCN (Portable Chess Notation) implementation for Ruby with comprehensive
|
|
119
119
|
game record representation
|