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