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