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.
@@ -2,7 +2,7 @@
2
2
 
3
3
  require "sashite/cgsn"
4
4
  require "sashite/feen"
5
- require "sashite/pmn"
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: "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR / C/c")
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: { event: "World Championship" },
27
+ # meta: {
28
+ # event: "World Championship",
29
+ # started_at: "2025-01-27T14:00:00Z"
30
+ # },
28
31
  # sides: {
29
- # first: { name: "Carlsen", elo: 2830, style: "CHESS" },
30
- # second: { name: "Nakamura", elo: 2794, style: "chess" }
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: "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR / C/c",
33
- # moves: [["e2", "e4"], ["c7", "c5"]],
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 moves in PMN format (optional, defaults to [])
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.map { |move| ::Sashite::Pmn.parse(move) }.freeze
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<Sashite::Pmn::Move>] frozen array of moves
138
+ # @return [Array<Array>] frozen array of [PAN, seconds] tuples
114
139
  #
115
140
  # @example
116
- # game.moves # => [#<Sashite::Pmn::Move ...>, ...]
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 # => { name: "Carlsen", elo: 2830, style: "CHESS" }
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 # => { name: "Nakamura", elo: 2794, style: "chess" }
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 [Sashite::Pmn::Move, nil] move at index or nil if out of bounds
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) # => #<Sashite::Pmn::Move ...>
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] move in PMN format
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", "f3"])
214
+ # new_game = game.add_move(["g1-f3", 1.8])
187
215
  def add_move(move)
188
- new_moves = @moves.map(&:to_a) + [move]
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
- # Metadata Shortcuts
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 game start date
265
+ # Get total time spent by second player
203
266
  #
204
- # @return [String, nil] start date in ISO 8601 format
267
+ # @return [Float] sum of seconds for moves at odd indices
205
268
  #
206
269
  # @example
207
- # game.started_on # => "2024-11-20"
208
- def started_on
209
- @meta[:started_on]
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
- # Get game completion timestamp
277
+ # ========================================================================
278
+ # Metadata Shortcuts
279
+ # ========================================================================
280
+
281
+ # Get game start timestamp
213
282
  #
214
- # @return [String, nil] completion timestamp in ISO 8601 format with UTC
283
+ # @return [String, nil] start timestamp in ISO 8601 format
215
284
  #
216
285
  # @example
217
- # game.finished_at # => "2024-11-20T18:45:00Z"
218
- def finished_at
219
- @meta[:finished_at]
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.map(&:to_a),
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.map(&:to_a),
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", "e4"], ["e7", "e5"]])
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.map(&:to_a)
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" => "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR / C/c"
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" => "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR / C/c",
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.3.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-pmn
41
+ name: sashite-pan
42
42
  requirement: !ruby/object:Gem::Requirement
43
43
  requirements:
44
44
  - - "~>"
45
45
  - !ruby/object:Gem::Version
46
- version: '1.1'
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: '1.1'
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.6.9
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