sashite-pcn 0.2.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.
@@ -1,436 +1,482 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "sashite/cgsn"
4
+ require "sashite/feen"
5
+ require "sashite/pan"
6
+ require "sashite/snn"
7
+
8
+ require_relative "game/meta"
9
+ require_relative "game/sides"
10
+
3
11
  module Sashite
4
12
  module Pcn
5
- # Immutable representation of a complete game record.
13
+ # Represents a complete game record in PCN (Portable Chess Notation) format.
14
+ #
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.
18
+ #
19
+ # All parameters are validated at initialization time. An instance of Game
20
+ # cannot be created with invalid data.
6
21
  #
7
- # A Game consists of:
8
- # - setup: Initial position (FEEN format) [required]
9
- # - moves: Sequence of moves (PMN format) [required]
10
- # - status: Game status [optional]
11
- # - meta: Metadata [optional]
12
- # - sides: Player information [optional]
22
+ # @example Minimal game
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")
13
24
  #
14
- # @see https://sashite.dev/specs/pcn/1.0.0/
25
+ # @example Complete game with time tracking
26
+ # game = Game.new(
27
+ # meta: {
28
+ # event: "World Championship",
29
+ # started_at: "2025-01-27T14:00:00Z"
30
+ # },
31
+ # sides: {
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
+ # }
50
+ # },
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
+ # ],
56
+ # status: "in_progress"
57
+ # )
15
58
  class Game
16
- # Valid status values according to PCN specification.
17
- VALID_STATUSES = %w[
18
- in_progress
19
- checkmate
20
- stalemate
21
- bare_king
22
- mare_king
23
- resignation
24
- illegal_move
25
- time_limit
26
- move_limit
27
- repetition
28
- agreement
29
- insufficient
30
- ].freeze
31
-
32
- # @return [Feen::Position] Initial position
33
- attr_reader :setup
34
-
35
- # @return [Array<Pmn::Move>] Move sequence
36
- attr_reader :moves
37
-
38
- # @return [String, nil] Game status
39
- attr_reader :status
40
-
41
- # @return [Meta, nil] Metadata
42
- attr_reader :meta
43
-
44
- # @return [Sides, nil] Player information
45
- attr_reader :sides
46
-
47
- # Parse a PCN hash into a Game object.
48
- #
49
- # @param hash [Hash] PCN document hash
50
- # @return [Game] Immutable game object
51
- # @raise [Error::Parse] If structure is invalid
52
- # @raise [Error::Validation] If format is invalid
59
+ # Error messages
60
+ ERROR_MISSING_SETUP = "setup is required"
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"
65
+ ERROR_INVALID_META = "meta must be a hash"
66
+ ERROR_INVALID_SIDES = "sides must be a hash"
67
+
68
+ # Status constants
69
+ STATUS_IN_PROGRESS = "in_progress"
70
+
71
+ # Create a new game instance
53
72
  #
54
- # @example
55
- # game = Game.parse({
56
- # "setup" => "8/8/8/8/8/8/8/8 / C/c",
57
- # "moves" => []
58
- # })
59
- def self.parse(hash)
60
- validate_structure!(hash)
61
-
62
- setup = parse_setup(hash["setup"])
63
- moves = parse_moves(hash["moves"])
64
- status = hash["status"]
65
- meta = parse_meta(hash["meta"])
66
- sides = parse_sides(hash["sides"])
67
-
68
- new(
69
- setup: setup,
70
- moves: moves,
71
- status: status,
72
- meta: meta,
73
- sides: sides
74
- )
73
+ # @param setup [String] initial position in FEEN format (required)
74
+ # @param moves [Array<Array>] sequence of [PAN, seconds] tuples (optional, defaults to [])
75
+ # @param status [String, nil] game status in CGSN format (optional)
76
+ # @param meta [Hash] game metadata (optional)
77
+ # @param sides [Hash] player information with time control (optional)
78
+ # @raise [ArgumentError] if required fields are missing or invalid
79
+ def initialize(setup:, moves: [], status: nil, meta: {}, sides: {})
80
+ # Validate and parse setup (required)
81
+ raise ::ArgumentError, ERROR_MISSING_SETUP if setup.nil?
82
+ @setup = ::Sashite::Feen.parse(setup)
83
+
84
+ # Validate and parse moves (optional, defaults to [])
85
+ raise ::ArgumentError, ERROR_INVALID_MOVES unless moves.is_a?(::Array)
86
+ @moves = validate_and_parse_moves(moves).freeze
87
+
88
+ # Validate and parse status (optional)
89
+ @status = status.nil? ? nil : ::Sashite::Cgsn.parse(status)
90
+
91
+ # Validate meta (optional)
92
+ raise ::ArgumentError, ERROR_INVALID_META unless meta.is_a?(::Hash)
93
+ @meta = Meta.new(**meta.transform_keys(&:to_sym))
94
+
95
+ # Validate sides (optional)
96
+ raise ::ArgumentError, ERROR_INVALID_SIDES unless sides.is_a?(::Hash)
97
+ @sides = Sides.new(**sides.transform_keys(&:to_sym))
98
+
99
+ freeze
75
100
  end
76
101
 
77
- # Validate a PCN hash without raising exceptions.
102
+ # ========================================================================
103
+ # Core Data Access
104
+ # ========================================================================
105
+
106
+ # Get initial position
78
107
  #
79
- # @param hash [Hash] PCN document hash
80
- # @return [Boolean] true if valid, false otherwise
108
+ # @return [Sashite::Feen::Position] initial position in FEEN format
81
109
  #
82
110
  # @example
83
- # Game.valid?({ "setup" => "...", "moves" => [] }) # => true
84
- def self.valid?(hash)
85
- parse(hash)
86
- true
87
- rescue Error
88
- false
111
+ # game.setup # => #<Sashite::Feen::Position ...>
112
+ def setup
113
+ @setup
89
114
  end
90
115
 
91
- # Create a new Game.
116
+ # Get game metadata
92
117
  #
93
- # @param setup [Feen::Position, String] Initial position
94
- # @param moves [Array<Pmn::Move, Array>] Move sequence
95
- # @param status [String, nil] Game status
96
- # @param meta [Meta, Hash, nil] Metadata
97
- # @param sides [Sides, Hash, nil] Player information
98
- # @raise [Error::Validation] If validation fails
118
+ # @return [Meta] metadata object
99
119
  #
100
120
  # @example
101
- # game = Game.new(
102
- # setup: Feen.parse("8/8/8/8/8/8/8/8 / C/c"),
103
- # moves: []
104
- # )
105
- def initialize(setup:, moves:, status: nil, meta: nil, sides: nil)
106
- @setup = normalize_setup(setup)
107
- @moves = normalize_moves(moves)
108
- @status = normalize_status(status)
109
- @meta = normalize_meta(meta)
110
- @sides = normalize_sides(sides)
111
-
112
- validate!
113
-
114
- freeze
121
+ # game.meta # => #<Sashite::Pcn::Game::Meta ...>
122
+ def meta
123
+ @meta
115
124
  end
116
125
 
117
- # Check if the game is valid.
126
+ # Get player information
118
127
  #
119
- # @return [Boolean] true if valid
120
- def valid?
121
- validate!
122
- true
123
- rescue Error
124
- false
125
- end
126
-
127
- # Get the number of moves.
128
+ # @return [Sides] sides object
128
129
  #
129
- # @return [Integer] Move count
130
- def move_count
131
- moves.size
130
+ # @example
131
+ # game.sides # => #<Sashite::Pcn::Game::Sides ...>
132
+ def sides
133
+ @sides
132
134
  end
133
- alias size move_count
134
- alias length move_count
135
135
 
136
- # Check if no moves have been played.
136
+ # Get move sequence with time tracking
137
+ #
138
+ # @return [Array<Array>] frozen array of [PAN, seconds] tuples
137
139
  #
138
- # @return [Boolean] true if no moves
139
- def empty?
140
- moves.empty?
140
+ # @example
141
+ # game.moves # => [["e2-e4", 2.5], ["e7-e5", 3.1]]
142
+ def moves
143
+ @moves
141
144
  end
142
145
 
143
- # Check if status is present.
146
+ # Get game status
147
+ #
148
+ # @return [Sashite::Cgsn::Status, nil] status object or nil
144
149
  #
145
- # @return [Boolean] true if status field exists
146
- def has_status?
147
- !status.nil?
150
+ # @example
151
+ # game.status # => #<Sashite::Cgsn::Status ...>
152
+ def status
153
+ @status
148
154
  end
149
155
 
150
- # Check if metadata is present.
156
+ # ========================================================================
157
+ # Player Access
158
+ # ========================================================================
159
+
160
+ # Get first player information
161
+ #
162
+ # @return [Hash, nil] first player data or nil if not defined
151
163
  #
152
- # @return [Boolean] true if meta field exists
153
- def has_meta?
154
- !meta.nil?
164
+ # @example
165
+ # game.first_player
166
+ # # => { name: "Carlsen", elo: 2830, style: "CHESS", periods: [...] }
167
+ def first_player
168
+ @sides.first
155
169
  end
156
170
 
157
- # Check if player information is present.
171
+ # Get second player information
172
+ #
173
+ # @return [Hash, nil] second player data or nil if not defined
158
174
  #
159
- # @return [Boolean] true if sides field exists
160
- def has_sides?
161
- !sides.nil?
175
+ # @example
176
+ # game.second_player
177
+ # # => { name: "Nakamura", elo: 2794, style: "chess", periods: [...] }
178
+ def second_player
179
+ @sides.second
162
180
  end
163
181
 
164
- # Add a move to the game.
182
+ # ========================================================================
183
+ # Move Operations
184
+ # ========================================================================
185
+
186
+ # Get move at specified index
165
187
  #
166
- # @param move [Pmn::Move, Array] Move to add
167
- # @return [Game] New game with added move
188
+ # @param index [Integer] move index (0-based)
189
+ # @return [Array, nil] [PAN, seconds] tuple at index or nil if out of bounds
168
190
  #
169
191
  # @example
170
- # new_game = game.add_move(["e2", "e4", "C:P"])
171
- def add_move(move)
172
- normalized_move = move.is_a?(::Sashite::Pmn::Move) ? move : ::Sashite::Pmn.parse(move)
173
-
174
- self.class.new(
175
- setup: setup,
176
- moves: moves + [normalized_move],
177
- status: status,
178
- meta: meta,
179
- sides: sides
180
- )
192
+ # game.move_at(0) # => ["e2-e4", 2.5]
193
+ def move_at(index)
194
+ @moves[index]
181
195
  end
182
196
 
183
- # Update the game status.
197
+ # Get total number of moves
184
198
  #
185
- # @param new_status [String, nil] New status value
186
- # @return [Game] New game with updated status
199
+ # @return [Integer] number of moves in the game
187
200
  #
188
201
  # @example
189
- # finished = game.with_status("checkmate")
190
- def with_status(new_status)
191
- self.class.new(
192
- setup: setup,
193
- moves: moves,
194
- status: new_status,
195
- meta: meta,
196
- sides: sides
197
- )
202
+ # game.move_count # => 2
203
+ def move_count
204
+ @moves.length
198
205
  end
199
206
 
200
- # Update the metadata.
207
+ # Add a move to the game
201
208
  #
202
- # @param new_meta [Meta, Hash, nil] New metadata
203
- # @return [Game] New game with updated metadata
209
+ # @param move [Array] [PAN, seconds] tuple
210
+ # @return [Game] new game instance with added move
211
+ # @raise [ArgumentError] if move format is invalid
204
212
  #
205
213
  # @example
206
- # updated = game.with_meta(Meta.new(event: "Tournament"))
207
- def with_meta(new_meta)
214
+ # new_game = game.add_move(["g1-f3", 1.8])
215
+ def add_move(move)
216
+ # Validate the new move
217
+ validate_move_tuple(move)
218
+
219
+ new_moves = @moves + [move]
208
220
  self.class.new(
209
- setup: setup,
210
- moves: moves,
211
- status: status,
212
- meta: new_meta,
213
- sides: sides
221
+ setup: @setup.to_s,
222
+ moves: new_moves,
223
+ status: @status&.to_s,
224
+ meta: @meta.to_h,
225
+ sides: @sides.to_h
214
226
  )
215
227
  end
216
228
 
217
- # Update the player information.
229
+ # Get the PAN notation from a move
218
230
  #
219
- # @param new_sides [Sides, Hash, nil] New player information
220
- # @return [Game] New game with updated sides
231
+ # @param index [Integer] move index
232
+ # @return [String, nil] PAN notation or nil if out of bounds
221
233
  #
222
234
  # @example
223
- # updated = game.with_sides(Sides.new(first: player1, second: player2))
224
- def with_sides(new_sides)
225
- self.class.new(
226
- setup: setup,
227
- moves: moves,
228
- status: status,
229
- meta: meta,
230
- sides: new_sides
231
- )
235
+ # game.pan_at(0) # => "e2-e4"
236
+ def pan_at(index)
237
+ move = @moves[index]
238
+ move ? move[0] : nil
232
239
  end
233
240
 
234
- # Convert to hash representation.
241
+ # Get the seconds spent on a move
235
242
  #
236
- # @return [Hash] PCN document hash
243
+ # @param index [Integer] move index
244
+ # @return [Float, nil] seconds or nil if out of bounds
237
245
  #
238
246
  # @example
239
- # game.to_h # => { "setup" => "...", "moves" => [...], ... }
240
- def to_h
241
- hash = {
242
- "setup" => setup.to_s,
243
- "moves" => moves.map(&:to_a)
244
- }
245
-
246
- hash["status"] = status if has_status?
247
- hash["meta"] = meta.to_h if has_meta?
248
- hash["sides"] = sides.to_h if has_sides?
249
-
250
- hash
247
+ # game.seconds_at(0) # => 2.5
248
+ def seconds_at(index)
249
+ move = @moves[index]
250
+ move ? move[1] : nil
251
251
  end
252
252
 
253
- # String representation.
253
+ # Get total time spent by first player
254
254
  #
255
- # @return [String] Inspectable representation
256
- def to_s
257
- "#<#{self.class} setup=#{setup.to_s.inspect} moves=#{moves.size} status=#{status.inspect}>"
258
- end
259
- alias inspect to_s
260
-
261
- # Equality comparison.
262
- #
263
- # @param other [Game] Other game
264
- # @return [Boolean] true if equal
265
- def ==(other)
266
- other.is_a?(self.class) &&
267
- other.setup == setup &&
268
- other.moves == moves &&
269
- other.status == status &&
270
- other.meta == meta &&
271
- other.sides == sides
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] }
272
263
  end
273
- alias eql? ==
274
264
 
275
- # Hash code for equality.
265
+ # Get total time spent by second player
276
266
  #
277
- # @return [Integer] Hash code
278
- def hash
279
- [self.class, setup, moves, status, meta, sides].hash
267
+ # @return [Float] sum of seconds for moves at odd indices
268
+ #
269
+ # @example
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] }
280
275
  end
281
276
 
282
- private
283
-
284
- # Validate PCN hash structure.
285
- def self.validate_structure!(hash)
286
- raise Error::Parse, "PCN document must be a Hash, got #{hash.class}" unless hash.is_a?(::Hash)
287
-
288
- raise Error::Parse, "Missing required field 'setup'" unless hash.key?("setup")
277
+ # ========================================================================
278
+ # Metadata Shortcuts
279
+ # ========================================================================
289
280
 
290
- raise Error::Parse, "Missing required field 'moves'" unless hash.key?("moves")
291
-
292
- return if hash["moves"].is_a?(::Array)
293
-
294
- raise Error::Parse, "'moves' must be an Array, got #{hash['moves'].class}"
281
+ # Get game start timestamp
282
+ #
283
+ # @return [String, nil] start timestamp in ISO 8601 format
284
+ #
285
+ # @example
286
+ # game.started_at # => "2025-01-27T14:00:00Z"
287
+ def started_at
288
+ @meta[:started_at]
295
289
  end
296
290
 
297
- # Parse setup field.
298
- def self.parse_setup(value)
299
- ::Sashite::Feen.parse(value)
300
- rescue ::Sashite::Feen::Error => e
301
- raise Error::Validation, "Invalid setup: #{e.message}"
291
+ # Get event name
292
+ #
293
+ # @return [String, nil] event name
294
+ #
295
+ # @example
296
+ # game.event # => "World Championship"
297
+ def event
298
+ @meta[:event]
302
299
  end
303
300
 
304
- # Parse moves field.
305
- def self.parse_moves(array)
306
- array.map.with_index do |move_array, index|
307
- ::Sashite::Pmn.parse(move_array)
308
- rescue ::Sashite::Pmn::Error => e
309
- raise Error::Validation, "Invalid move at index #{index}: #{e.message}"
310
- end
301
+ # Get event location
302
+ #
303
+ # @return [String, nil] location
304
+ #
305
+ # @example
306
+ # game.location # => "London"
307
+ def location
308
+ @meta[:location]
311
309
  end
312
310
 
313
- # Parse meta field.
314
- def self.parse_meta(value)
315
- return nil if value.nil?
316
-
317
- Meta.parse(value)
318
- rescue Error => e
319
- raise Error::Validation, "Invalid meta: #{e.message}"
311
+ # Get round number
312
+ #
313
+ # @return [Integer, nil] round number
314
+ #
315
+ # @example
316
+ # game.round # => 5
317
+ def round
318
+ @meta[:round]
320
319
  end
321
320
 
322
- # Parse sides field.
323
- def self.parse_sides(value)
324
- return nil if value.nil?
321
+ # ========================================================================
322
+ # Transformations
323
+ # ========================================================================
325
324
 
326
- Sides.parse(value)
327
- rescue Error => e
328
- raise Error::Validation, "Invalid sides: #{e.message}"
325
+ # Create new game with updated status
326
+ #
327
+ # @param new_status [String, nil] new status value
328
+ # @return [Game] new game instance with updated status
329
+ #
330
+ # @example
331
+ # updated = game.with_status("resignation")
332
+ def with_status(new_status)
333
+ self.class.new(
334
+ setup: @setup.to_s,
335
+ moves: @moves,
336
+ status: new_status,
337
+ meta: @meta.to_h,
338
+ sides: @sides.to_h
339
+ )
329
340
  end
330
341
 
331
- # Normalize setup to Position object.
332
- def normalize_setup(value)
333
- return value if value.is_a?(::Sashite::Feen::Position)
334
-
335
- ::Sashite::Feen.parse(value)
336
- rescue ::Sashite::Feen::Error => e
337
- raise Error::Validation, "Invalid setup: #{e.message}"
342
+ # Create new game with updated metadata
343
+ #
344
+ # @param new_meta [Hash] metadata to merge
345
+ # @return [Game] new game instance with updated metadata
346
+ #
347
+ # @example
348
+ # updated = game.with_meta(event: "Casual Game", round: 1)
349
+ def with_meta(**new_meta)
350
+ merged_meta = @meta.to_h.merge(new_meta)
351
+ self.class.new(
352
+ setup: @setup.to_s,
353
+ moves: @moves,
354
+ status: @status&.to_s,
355
+ meta: merged_meta,
356
+ sides: @sides.to_h
357
+ )
338
358
  end
339
359
 
340
- # Normalize moves to array of Move objects.
341
- def normalize_moves(value)
342
- raise Error::Validation, "Moves must be an Array, got #{value.class}" unless value.is_a?(::Array)
343
-
344
- value.map.with_index do |move, index|
345
- next move if move.is_a?(::Sashite::Pmn::Move)
346
-
347
- ::Sashite::Pmn.parse(move)
348
- rescue ::Sashite::Pmn::Error => e
349
- raise Error::Validation, "Invalid move at index #{index}: #{e.message}"
350
- end
360
+ # Create new game with specified move sequence
361
+ #
362
+ # @param new_moves [Array<Array>] new move sequence of [PAN, seconds] tuples
363
+ # @return [Game] new game instance with new moves
364
+ # @raise [ArgumentError] if move format is invalid
365
+ #
366
+ # @example
367
+ # updated = game.with_moves([["e2-e4", 2.0], ["e7-e5", 3.0]])
368
+ def with_moves(new_moves)
369
+ self.class.new(
370
+ setup: @setup.to_s,
371
+ moves: new_moves,
372
+ status: @status&.to_s,
373
+ meta: @meta.to_h,
374
+ sides: @sides.to_h
375
+ )
351
376
  end
352
377
 
353
- # Normalize status.
354
- def normalize_status(value)
355
- return nil if value.nil?
378
+ # ========================================================================
379
+ # Predicates
380
+ # ========================================================================
356
381
 
357
- raise Error::Validation, "Status must be a String, got #{value.class}" unless value.is_a?(::String)
382
+ # Check if the game is in progress
383
+ #
384
+ # @return [Boolean, nil] true if in progress, false if finished, nil if indeterminate
385
+ #
386
+ # @example
387
+ # game.in_progress? # => true
388
+ def in_progress?
389
+ return if @status.nil?
358
390
 
359
- value
391
+ @status.to_s == STATUS_IN_PROGRESS
360
392
  end
361
393
 
362
- # Normalize meta.
363
- def normalize_meta(value)
364
- return nil if value.nil?
365
- return value if value.is_a?(Meta)
394
+ # Check if the game is finished
395
+ #
396
+ # @return [Boolean, nil] true if finished, false if in progress, nil if indeterminate
397
+ #
398
+ # @example
399
+ # game.finished? # => false
400
+ def finished?
401
+ return if @status.nil?
366
402
 
367
- Meta.parse(value)
403
+ !in_progress?
368
404
  end
369
405
 
370
- # Normalize sides.
371
- def normalize_sides(value)
372
- return nil if value.nil?
373
- return value if value.is_a?(Sides)
406
+ # ========================================================================
407
+ # Serialization
408
+ # ========================================================================
374
409
 
375
- Sides.parse(value)
376
- end
410
+ # Convert to hash representation
411
+ #
412
+ # @return [Hash] hash with string keys ready for JSON serialization
413
+ #
414
+ # @example
415
+ # game.to_h
416
+ # # => {
417
+ # # "setup" => "...",
418
+ # # "moves" => [["e2-e4", 2.5], ["e7-e5", 3.1]],
419
+ # # "status" => "in_progress",
420
+ # # "meta" => {...},
421
+ # # "sides" => {...}
422
+ # # }
423
+ def to_h
424
+ result = { "setup" => @setup.to_s }
377
425
 
378
- # Validate all fields.
379
- def validate!
380
- validate_setup!
381
- validate_moves!
382
- validate_status!
383
- validate_meta!
384
- validate_sides!
385
- end
426
+ # Always include moves array (even if empty)
427
+ result["moves"] = @moves
386
428
 
387
- # Validate setup field.
388
- def validate_setup!
389
- return if setup.is_a?(::Sashite::Feen::Position)
429
+ # Include optional fields if present
430
+ result["status"] = @status.to_s if @status
431
+ result["meta"] = @meta.to_h unless @meta.empty?
432
+ result["sides"] = @sides.to_h unless @sides.empty?
390
433
 
391
- raise Error::Validation, "Setup must be a Feen::Position"
434
+ result
392
435
  end
393
436
 
394
- # Validate moves field.
395
- def validate_moves!
396
- raise Error::Validation, "Moves must be an Array" unless moves.is_a?(::Array)
437
+ private
397
438
 
398
- moves.each_with_index do |move, index|
399
- raise Error::Validation, "Move at index #{index} must be a Pmn::Move" unless move.is_a?(::Sashite::Pmn::Move)
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)
400
447
  end
401
448
  end
402
449
 
403
- # Validate status field.
404
- def validate_status!
405
- return if status.nil?
406
-
407
- raise Error::Validation, "Status must be a String, got #{status.class}" unless status.is_a?(::String)
408
-
409
- return if VALID_STATUSES.include?(status)
410
-
411
- raise Error::Validation, "Invalid status value: #{status.inspect}"
412
- end
413
-
414
- # Validate meta field.
415
- def validate_meta!
416
- return if meta.nil?
417
-
418
- raise Error::Validation, "Meta must be a Meta object" unless meta.is_a?(Meta)
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}" : ""
419
457
 
420
- return if meta.valid?
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
421
460
 
422
- raise Error::Validation, "Meta validation failed"
423
- end
461
+ pan_notation, seconds = move
424
462
 
425
- # Validate sides field.
426
- def validate_sides!
427
- return if sides.nil?
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
428
467
 
429
- raise Error::Validation, "Sides must be a Sides object" unless sides.is_a?(Sides)
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
430
474
 
431
- return if sides.valid?
475
+ # Validate seconds (must be a non-negative number)
476
+ raise ::ArgumentError, "#{ERROR_INVALID_SECONDS}#{position}" unless seconds.is_a?(::Numeric) && seconds >= 0
432
477
 
433
- raise Error::Validation, "Sides validation failed"
478
+ # Return the move tuple with seconds as float
479
+ [pan_notation, seconds.to_f].freeze
434
480
  end
435
481
  end
436
482
  end