sashite-pcn 0.1.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.
@@ -0,0 +1,436 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sashite
4
+ module Pcn
5
+ # Immutable representation of a complete game record.
6
+ #
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]
13
+ #
14
+ # @see https://sashite.dev/specs/pcn/1.0.0/
15
+ 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
52
+ #
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
+ )
74
+ end
75
+
76
+ # Validate a PCN hash without raising exceptions.
77
+ #
78
+ # @param hash [Hash] PCN document hash
79
+ # @return [Boolean] true if valid, false otherwise
80
+ #
81
+ # @example
82
+ # Game.valid?({ "setup" => "...", "moves" => [] }) # => true
83
+ def self.valid?(hash)
84
+ parse(hash)
85
+ true
86
+ rescue Error
87
+ false
88
+ end
89
+
90
+ # Create a new Game.
91
+ #
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
98
+ #
99
+ # @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
114
+ end
115
+
116
+ # Check if the game is valid.
117
+ #
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.
127
+ #
128
+ # @return [Integer] Move count
129
+ def move_count
130
+ moves.size
131
+ end
132
+ alias size move_count
133
+ alias length move_count
134
+
135
+ # Check if no moves have been played.
136
+ #
137
+ # @return [Boolean] true if no moves
138
+ def empty?
139
+ moves.empty?
140
+ end
141
+
142
+ # Check if status is present.
143
+ #
144
+ # @return [Boolean] true if status field exists
145
+ def has_status?
146
+ !status.nil?
147
+ end
148
+
149
+ # Check if metadata is present.
150
+ #
151
+ # @return [Boolean] true if meta field exists
152
+ def has_meta?
153
+ !meta.nil?
154
+ end
155
+
156
+ # Check if player information is present.
157
+ #
158
+ # @return [Boolean] true if sides field exists
159
+ def has_sides?
160
+ !sides.nil?
161
+ end
162
+
163
+ # Add a move to the game.
164
+ #
165
+ # @param move [Pmn::Move, Array] Move to add
166
+ # @return [Game] New game with added move
167
+ #
168
+ # @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
+ )
180
+ end
181
+
182
+ # Update the game status.
183
+ #
184
+ # @param new_status [String, nil] New status value
185
+ # @return [Game] New game with updated status
186
+ #
187
+ # @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
+ )
197
+ end
198
+
199
+ # Update the metadata.
200
+ #
201
+ # @param new_meta [Meta, Hash, nil] New metadata
202
+ # @return [Game] New game with updated metadata
203
+ #
204
+ # @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
+ )
214
+ end
215
+
216
+ # Update the player information.
217
+ #
218
+ # @param new_sides [Sides, Hash, nil] New player information
219
+ # @return [Game] New game with updated sides
220
+ #
221
+ # @example
222
+ # updated = game.with_sides(Sides.new(first: player1, second: player2))
223
+ def with_sides(new_sides)
224
+ self.class.new(
225
+ setup: setup,
226
+ moves: moves,
227
+ status: status,
228
+ meta: meta,
229
+ sides: new_sides
230
+ )
231
+ end
232
+
233
+ # Convert to hash representation.
234
+ #
235
+ # @return [Hash] PCN document hash
236
+ #
237
+ # @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
250
+ end
251
+
252
+ # String representation.
253
+ #
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.
275
+ #
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}"
328
+ end
329
+
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}"
337
+ end
338
+
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
350
+ end
351
+
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
359
+ end
360
+
361
+ # Normalize meta.
362
+ def normalize_meta(value)
363
+ return nil if value.nil?
364
+ return value if value.is_a?(Meta)
365
+
366
+ Meta.parse(value)
367
+ end
368
+
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)
375
+ end
376
+
377
+ # Validate all fields.
378
+ def validate!
379
+ validate_setup!
380
+ validate_moves!
381
+ validate_status!
382
+ validate_meta!
383
+ validate_sides!
384
+ end
385
+
386
+ # Validate setup field.
387
+ def validate_setup!
388
+ return if setup.is_a?(::Sashite::Feen::Position)
389
+
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)
396
+
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
400
+ end
401
+
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)
409
+
410
+ raise Error::Validation, "Invalid status value: #{status.inspect}"
411
+ end
412
+
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)
418
+
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?
427
+
428
+ raise Error::Validation, "Sides must be a Sides object" unless sides.is_a?(Sides)
429
+
430
+ return if sides.valid?
431
+
432
+ raise Error::Validation, "Sides validation failed"
433
+ end
434
+ end
435
+ end
436
+ end
@@ -0,0 +1,275 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sashite
4
+ module Pcn
5
+ # Immutable representation of game metadata.
6
+ #
7
+ # All fields are optional. Metadata provides contextual information
8
+ # about the game session.
9
+ #
10
+ # @see https://sashite.dev/specs/pcn/1.0.0/
11
+ class Meta
12
+ # ISO 8601 date format: YYYY-MM-DD
13
+ DATE_PATTERN = /\A\d{4}-\d{2}-\d{2}\z/
14
+
15
+ # ISO 8601 datetime format with UTC timezone: YYYY-MM-DDTHH:MM:SSZ
16
+ DATETIME_PATTERN = /\A\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z\z/
17
+
18
+ # Absolute URL pattern (http:// or https://)
19
+ URL_PATTERN = %r{\Ahttps?://.+\z}
20
+
21
+ # @return [String, nil] Game name or opening identification
22
+ attr_reader :name
23
+
24
+ # @return [String, nil] Tournament or event name
25
+ attr_reader :event
26
+
27
+ # @return [String, nil] Physical or virtual venue
28
+ attr_reader :location
29
+
30
+ # @return [Integer, nil] Round number in tournament context
31
+ attr_reader :round
32
+
33
+ # @return [String, nil] Game start date (ISO 8601: YYYY-MM-DD)
34
+ attr_reader :started_on
35
+
36
+ # @return [String, nil] Game completion timestamp (ISO 8601 UTC: YYYY-MM-DDTHH:MM:SSZ)
37
+ attr_reader :finished_at
38
+
39
+ # @return [String, nil] Reference link to external resource
40
+ attr_reader :href
41
+
42
+ # Parse a meta hash into a Meta object.
43
+ #
44
+ # @param hash [Hash] Metadata hash
45
+ # @return [Meta] Immutable meta object
46
+ # @raise [Error::Validation] If validation fails
47
+ #
48
+ # @example
49
+ # meta = Meta.parse({
50
+ # "event" => "World Championship",
51
+ # "round" => 5
52
+ # })
53
+ def self.parse(hash)
54
+ raise Error::Validation, "Meta must be a Hash, got #{hash.class}" unless hash.is_a?(::Hash)
55
+
56
+ new(
57
+ name: hash["name"],
58
+ event: hash["event"],
59
+ location: hash["location"],
60
+ round: hash["round"],
61
+ started_on: hash["started_on"],
62
+ finished_at: hash["finished_at"],
63
+ href: hash["href"]
64
+ )
65
+ end
66
+
67
+ # Validate a meta hash without raising exceptions.
68
+ #
69
+ # @param hash [Hash] Metadata hash
70
+ # @return [Boolean] true if valid, false otherwise
71
+ #
72
+ # @example
73
+ # Meta.valid?({ "event" => "Tournament" }) # => true
74
+ def self.valid?(hash)
75
+ parse(hash)
76
+ true
77
+ rescue Error
78
+ false
79
+ end
80
+
81
+ # Create a new Meta.
82
+ #
83
+ # @param name [String, nil] Game name
84
+ # @param event [String, nil] Event name
85
+ # @param location [String, nil] Location
86
+ # @param round [Integer, nil] Round number
87
+ # @param started_on [String, nil] Start date (YYYY-MM-DD)
88
+ # @param finished_at [String, nil] Finish timestamp (YYYY-MM-DDTHH:MM:SSZ)
89
+ # @param href [String, nil] Reference URL
90
+ # @raise [Error::Validation] If validation fails
91
+ #
92
+ # @example
93
+ # meta = Meta.new(
94
+ # event: "World Championship",
95
+ # round: 5,
96
+ # started_on: "2025-11-15"
97
+ # )
98
+ def initialize(name: nil, event: nil, location: nil, round: nil, started_on: nil, finished_at: nil, href: nil)
99
+ @name = name
100
+ @event = event
101
+ @location = location
102
+ @round = round
103
+ @started_on = started_on
104
+ @finished_at = finished_at
105
+ @href = href
106
+
107
+ validate!
108
+
109
+ freeze
110
+ end
111
+
112
+ # Check if the meta is valid.
113
+ #
114
+ # @return [Boolean] true if valid
115
+ def valid?
116
+ validate!
117
+ true
118
+ rescue Error
119
+ false
120
+ end
121
+
122
+ # Check if metadata is empty (all fields nil).
123
+ #
124
+ # @return [Boolean] true if all fields are nil
125
+ def empty?
126
+ name.nil? && event.nil? && location.nil? && round.nil? &&
127
+ started_on.nil? && finished_at.nil? && href.nil?
128
+ end
129
+
130
+ # Convert to hash representation.
131
+ #
132
+ # @return [Hash] Metadata hash (excludes nil values)
133
+ #
134
+ # @example
135
+ # meta.to_h # => { "event" => "Tournament", "round" => 5 }
136
+ def to_h
137
+ hash = {}
138
+
139
+ hash["name"] = name unless name.nil?
140
+ hash["event"] = event unless event.nil?
141
+ hash["location"] = location unless location.nil?
142
+ hash["round"] = round unless round.nil?
143
+ hash["started_on"] = started_on unless started_on.nil?
144
+ hash["finished_at"] = finished_at unless finished_at.nil?
145
+ hash["href"] = href unless href.nil?
146
+
147
+ hash
148
+ end
149
+
150
+ # String representation.
151
+ #
152
+ # @return [String] Inspectable representation
153
+ def to_s
154
+ fields = []
155
+ fields << "event=#{event.inspect}" unless event.nil?
156
+ fields << "round=#{round}" unless round.nil?
157
+ fields << "location=#{location.inspect}" unless location.nil?
158
+
159
+ "#<#{self.class} #{fields.join(' ')}>"
160
+ end
161
+ alias inspect to_s
162
+
163
+ # Equality comparison.
164
+ #
165
+ # @param other [Meta] Other meta
166
+ # @return [Boolean] true if equal
167
+ def ==(other)
168
+ other.is_a?(self.class) &&
169
+ other.name == name &&
170
+ other.event == event &&
171
+ other.location == location &&
172
+ other.round == round &&
173
+ other.started_on == started_on &&
174
+ other.finished_at == finished_at &&
175
+ other.href == href
176
+ end
177
+ alias eql? ==
178
+
179
+ # Hash code for equality.
180
+ #
181
+ # @return [Integer] Hash code
182
+ def hash
183
+ [self.class, name, event, location, round, started_on, finished_at, href].hash
184
+ end
185
+
186
+ private
187
+
188
+ # Validate all fields.
189
+ def validate!
190
+ validate_name!
191
+ validate_event!
192
+ validate_location!
193
+ validate_round!
194
+ validate_started_on!
195
+ validate_finished_at!
196
+ validate_href!
197
+ end
198
+
199
+ # Validate name field.
200
+ def validate_name!
201
+ return if name.nil?
202
+
203
+ return if name.is_a?(::String)
204
+
205
+ raise Error::Validation, "Meta 'name' must be a String, got #{name.class}"
206
+ end
207
+
208
+ # Validate event field.
209
+ def validate_event!
210
+ return if event.nil?
211
+
212
+ return if event.is_a?(::String)
213
+
214
+ raise Error::Validation, "Meta 'event' must be a String, got #{event.class}"
215
+ end
216
+
217
+ # Validate location field.
218
+ def validate_location!
219
+ return if location.nil?
220
+
221
+ return if location.is_a?(::String)
222
+
223
+ raise Error::Validation, "Meta 'location' must be a String, got #{location.class}"
224
+ end
225
+
226
+ # Validate round field.
227
+ def validate_round!
228
+ return if round.nil?
229
+
230
+ raise Error::Validation, "Meta 'round' must be an Integer, got #{round.class}" unless round.is_a?(::Integer)
231
+
232
+ return unless round < 1
233
+
234
+ raise Error::Validation, "Meta 'round' must be >= 1, got #{round}"
235
+ end
236
+
237
+ # Validate started_on field.
238
+ def validate_started_on!
239
+ return if started_on.nil?
240
+
241
+ unless started_on.is_a?(::String)
242
+ raise Error::Validation, "Meta 'started_on' must be a String, got #{started_on.class}"
243
+ end
244
+
245
+ return if DATE_PATTERN.match?(started_on)
246
+
247
+ raise Error::Validation, "Meta 'started_on' must match format YYYY-MM-DD, got #{started_on.inspect}"
248
+ end
249
+
250
+ # Validate finished_at field.
251
+ def validate_finished_at!
252
+ return if finished_at.nil?
253
+
254
+ unless finished_at.is_a?(::String)
255
+ raise Error::Validation, "Meta 'finished_at' must be a String, got #{finished_at.class}"
256
+ end
257
+
258
+ return if DATETIME_PATTERN.match?(finished_at)
259
+
260
+ raise Error::Validation, "Meta 'finished_at' must match format YYYY-MM-DDTHH:MM:SSZ, got #{finished_at.inspect}"
261
+ end
262
+
263
+ # Validate href field.
264
+ def validate_href!
265
+ return if href.nil?
266
+
267
+ raise Error::Validation, "Meta 'href' must be a String, got #{href.class}" unless href.is_a?(::String)
268
+
269
+ return if URL_PATTERN.match?(href)
270
+
271
+ raise Error::Validation, "Meta 'href' must be an absolute URL (http:// or https://), got #{href.inspect}"
272
+ end
273
+ end
274
+ end
275
+ end