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.
- checksums.yaml +7 -0
- data/LICENSE.md +21 -0
- data/README.md +509 -0
- data/lib/sashite/pcn/error.rb +38 -0
- data/lib/sashite/pcn/game.rb +436 -0
- data/lib/sashite/pcn/meta.rb +275 -0
- data/lib/sashite/pcn/player.rb +186 -0
- data/lib/sashite/pcn/sides.rb +194 -0
- data/lib/sashite/pcn.rb +68 -0
- data/lib/sashite-pcn.rb +14 -0
- metadata +107 -0
|
@@ -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
|