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.
@@ -0,0 +1,239 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sashite
4
+ module Pcn
5
+ class Game
6
+ # Represents game metadata with standard and custom fields
7
+ #
8
+ # All fields are optional. An empty Meta object (no metadata) is valid.
9
+ # Standard fields are validated, custom fields are accepted without validation.
10
+ #
11
+ # @example With standard fields
12
+ # meta = Meta.new(
13
+ # name: "Italian Game",
14
+ # event: "World Championship",
15
+ # location: "London",
16
+ # round: 5,
17
+ # started_at: "2025-01-27T14:00:00Z",
18
+ # href: "https://example.com/game/123"
19
+ # )
20
+ #
21
+ # @example With custom fields
22
+ # meta = Meta.new(
23
+ # event: "Online Tournament",
24
+ # platform: "lichess.org",
25
+ # time_control: "5+3",
26
+ # rated: true,
27
+ # opening_eco: "C50"
28
+ # )
29
+ #
30
+ # @example Empty metadata
31
+ # meta = Meta.new # Valid, no metadata
32
+ class Meta
33
+ # Error messages
34
+ ERROR_INVALID_NAME = "name must be a string"
35
+ ERROR_INVALID_EVENT = "event must be a string"
36
+ ERROR_INVALID_LOCATION = "location must be a string"
37
+ ERROR_INVALID_ROUND = "round must be a positive integer (>= 1)"
38
+ ERROR_INVALID_STARTED_AT = "started_at must be in ISO 8601 datetime format (e.g., 2025-01-27T14:00:00Z)"
39
+ ERROR_INVALID_HREF = "href must be an absolute URL (http:// or https://)"
40
+
41
+ # Standard field keys
42
+ STANDARD_FIELDS = %i[name event location round started_at href].freeze
43
+
44
+ # Regular expressions for validation
45
+ # ISO 8601 datetime - accepts various formats:
46
+ # - Basic: 2025-01-27T14:00:00Z
47
+ # - With milliseconds: 2025-01-27T14:00:00.123Z
48
+ # - With timezone offset: 2025-01-27T14:00:00+02:00
49
+ # - Local time without timezone: 2025-01-27T14:00:00
50
+ DATETIME_PATTERN = /\A\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})?\z/
51
+ URL_PATTERN = /\Ahttps?:\/\/.+/
52
+
53
+ # Create a new Meta instance
54
+ #
55
+ # @param fields [Hash] metadata with optional standard and custom fields
56
+ # @raise [ArgumentError] if standard field values don't meet validation requirements
57
+ def initialize(**fields)
58
+ @data = {}
59
+
60
+ # Process and validate each field
61
+ fields.each do |key, value|
62
+ validate_and_store(key, value)
63
+ end
64
+
65
+ @data.freeze
66
+ freeze
67
+ end
68
+
69
+ # Get a metadata value by key
70
+ #
71
+ # @param key [Symbol, String] the metadata key
72
+ # @return [Object, nil] the value or nil if not present
73
+ #
74
+ # @example
75
+ # meta[:event] # => "World Championship"
76
+ # meta["started_at"] # => "2025-01-27T14:00:00Z"
77
+ def [](key)
78
+ @data[key.to_sym]
79
+ end
80
+
81
+ # Check if no metadata is present
82
+ #
83
+ # @return [Boolean] true if no fields are defined
84
+ #
85
+ # @example
86
+ # meta.empty? # => true
87
+ def empty?
88
+ @data.empty?
89
+ end
90
+
91
+ # Get all metadata keys
92
+ #
93
+ # @return [Array<Symbol>] array of defined field keys
94
+ #
95
+ # @example
96
+ # meta.keys # => [:event, :round, :platform]
97
+ def keys
98
+ @data.keys
99
+ end
100
+
101
+ # Check if a metadata field is present
102
+ #
103
+ # @param key [Symbol, String] the metadata key
104
+ # @return [Boolean] true if the field is defined
105
+ #
106
+ # @example
107
+ # meta.key?(:event) # => true
108
+ # meta.key?("round") # => true
109
+ def key?(key)
110
+ @data.key?(key.to_sym)
111
+ end
112
+
113
+ # Iterate over each metadata field
114
+ #
115
+ # @yield [key, value] yields each key-value pair
116
+ # @return [Enumerator] if no block given
117
+ #
118
+ # @example
119
+ # meta.each { |k, v| puts "#{k}: #{v}" }
120
+ def each(&)
121
+ return @data.each unless block_given?
122
+
123
+ @data.each(&)
124
+ end
125
+
126
+ # Convert to hash representation
127
+ #
128
+ # @return [Hash] hash with all defined metadata fields
129
+ #
130
+ # @example
131
+ # meta.to_h
132
+ # # => {
133
+ # # event: "Tournament",
134
+ # # round: 5,
135
+ # # started_at: "2025-01-27T14:00:00Z",
136
+ # # platform: "lichess.org"
137
+ # # }
138
+ def to_h
139
+ @data.dup.freeze
140
+ end
141
+
142
+ # String representation for debugging
143
+ #
144
+ # @return [String] string representation
145
+ def inspect
146
+ "#<#{self.class.name} #{@data.inspect}>"
147
+ end
148
+
149
+ # Check equality with another Meta object
150
+ #
151
+ # @param other [Object] object to compare
152
+ # @return [Boolean] true if equal
153
+ def ==(other)
154
+ return false unless other.is_a?(self.class)
155
+
156
+ @data == other.to_h
157
+ end
158
+
159
+ alias eql? ==
160
+
161
+ # Hash code for use in collections
162
+ #
163
+ # @return [Integer] hash code
164
+ def hash
165
+ @data.hash
166
+ end
167
+
168
+ private
169
+
170
+ # Validate and store a field
171
+ #
172
+ # @param key [Symbol] the field key
173
+ # @param value [Object] the field value
174
+ # @raise [ArgumentError] if validation fails
175
+ def validate_and_store(key, value)
176
+ case key
177
+ when :name
178
+ validate_name(value)
179
+ when :event
180
+ validate_event(value)
181
+ when :location
182
+ validate_location(value)
183
+ when :round
184
+ validate_round(value)
185
+ when :started_at
186
+ validate_started_at(value)
187
+ when :href
188
+ validate_href(value)
189
+ else
190
+ # Custom fields are accepted without validation
191
+ @data[key] = value
192
+ return
193
+ end
194
+
195
+ # Store frozen value for standard string fields
196
+ @data[key] = value.is_a?(::String) ? value.freeze : value
197
+ end
198
+
199
+ # Validate name field
200
+ def validate_name(value)
201
+ raise ::ArgumentError, ERROR_INVALID_NAME unless value.is_a?(::String)
202
+ end
203
+
204
+ # Validate event field
205
+ def validate_event(value)
206
+ raise ::ArgumentError, ERROR_INVALID_EVENT unless value.is_a?(::String)
207
+ end
208
+
209
+ # Validate location field
210
+ def validate_location(value)
211
+ raise ::ArgumentError, ERROR_INVALID_LOCATION unless value.is_a?(::String)
212
+ end
213
+
214
+ # Validate round field (must be integer >= 1)
215
+ def validate_round(value)
216
+ raise ::ArgumentError, ERROR_INVALID_ROUND unless value.is_a?(::Integer)
217
+ raise ::ArgumentError, ERROR_INVALID_ROUND unless value >= 1
218
+ end
219
+
220
+ # Validate started_at field (ISO 8601 datetime format)
221
+ # Accepts various ISO 8601 formats:
222
+ # - 2025-01-27T14:00:00Z (UTC)
223
+ # - 2025-01-27T14:00:00+02:00 (with timezone offset)
224
+ # - 2025-01-27T14:00:00.123Z (with milliseconds)
225
+ # - 2025-01-27T14:00:00 (local time, no timezone)
226
+ def validate_started_at(value)
227
+ raise ::ArgumentError, ERROR_INVALID_STARTED_AT unless value.is_a?(::String)
228
+ raise ::ArgumentError, ERROR_INVALID_STARTED_AT unless value.match?(DATETIME_PATTERN)
229
+ end
230
+
231
+ # Validate href field (absolute URL with http:// or https://)
232
+ def validate_href(value)
233
+ raise ::ArgumentError, ERROR_INVALID_HREF unless value.is_a?(::String)
234
+ raise ::ArgumentError, ERROR_INVALID_HREF unless value.match?(URL_PATTERN)
235
+ end
236
+ end
237
+ end
238
+ end
239
+ end
@@ -0,0 +1,311 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sashite
4
+ module Pcn
5
+ class Game
6
+ class Sides
7
+ # Represents a single player with optional metadata and time control
8
+ #
9
+ # All fields are optional. An empty Player object (no information) is valid.
10
+ # The periods field defines time control settings for this player.
11
+ #
12
+ # @example Complete player with time control
13
+ # player = Player.new(
14
+ # name: "Carlsen",
15
+ # elo: 2830,
16
+ # style: "CHESS",
17
+ # periods: [
18
+ # { time: 5400, moves: 40, inc: 0 }, # 90 min for first 40 moves
19
+ # { time: 1800, moves: nil, inc: 30 } # 30 min + 30s/move for rest
20
+ # ]
21
+ # )
22
+ #
23
+ # @example Fischer/Increment time control (5+3 blitz)
24
+ # player = Player.new(
25
+ # name: "Player 1",
26
+ # periods: [
27
+ # { time: 300, moves: nil, inc: 3 } # 5 min + 3s increment
28
+ # ]
29
+ # )
30
+ #
31
+ # @example Byōyomi time control
32
+ # player = Player.new(
33
+ # name: "Yamada",
34
+ # style: "SHOGI",
35
+ # periods: [
36
+ # { time: 3600, moves: nil, inc: 0 }, # Main time: 1 hour
37
+ # { time: 60, moves: 1, inc: 0 }, # 60s per move (5 periods)
38
+ # { time: 60, moves: 1, inc: 0 },
39
+ # { time: 60, moves: 1, inc: 0 },
40
+ # { time: 60, moves: 1, inc: 0 },
41
+ # { time: 60, moves: 1, inc: 0 }
42
+ # ]
43
+ # )
44
+ #
45
+ # @example No time control (casual game)
46
+ # player = Player.new(name: "Casual Player", periods: [])
47
+ #
48
+ # @example Empty player
49
+ # player = Player.new # Valid, no player information
50
+ class Player
51
+ # Error messages
52
+ ERROR_INVALID_STYLE = "style must be a valid SNN string"
53
+ ERROR_INVALID_NAME = "name must be a string"
54
+ ERROR_INVALID_ELO = "elo must be a non-negative integer (>= 0)"
55
+ ERROR_INVALID_PERIODS = "periods must be an array"
56
+ ERROR_INVALID_PERIOD = "each period must be a hash"
57
+ ERROR_MISSING_TIME = "period must have 'time' field"
58
+ ERROR_INVALID_TIME = "time must be a non-negative integer (>= 0)"
59
+ ERROR_INVALID_MOVES = "moves must be nil or a positive integer (>= 1)"
60
+ ERROR_INVALID_INC = "inc must be a non-negative integer (>= 0)"
61
+
62
+ # Create a new Player instance
63
+ #
64
+ # @param style [String, nil] player style in SNN format (optional)
65
+ # @param name [String, nil] player name (optional)
66
+ # @param elo [Integer, nil] player Elo rating (optional, >= 0)
67
+ # @param periods [Array<Hash>, nil] time control periods (optional)
68
+ # @raise [ArgumentError] if field values don't meet constraints
69
+ def initialize(style: nil, name: nil, elo: nil, periods: nil)
70
+ # Validate and assign style (optional)
71
+ if style
72
+ raise ::ArgumentError, ERROR_INVALID_STYLE unless style.is_a?(::String)
73
+ @style = ::Sashite::Snn.parse(style)
74
+ else
75
+ @style = nil
76
+ end
77
+
78
+ # Validate and assign name (optional)
79
+ if name
80
+ raise ::ArgumentError, ERROR_INVALID_NAME unless name.is_a?(::String)
81
+ @name = name.freeze
82
+ else
83
+ @name = nil
84
+ end
85
+
86
+ # Validate and assign elo (optional, must be >= 0)
87
+ if elo
88
+ raise ::ArgumentError, ERROR_INVALID_ELO unless elo.is_a?(::Integer)
89
+ raise ::ArgumentError, ERROR_INVALID_ELO unless elo >= 0
90
+ @elo = elo
91
+ else
92
+ @elo = nil
93
+ end
94
+
95
+ # Validate and assign periods
96
+ periods = [] if periods.nil?
97
+
98
+ raise ::ArgumentError, ERROR_INVALID_PERIODS unless periods.is_a?(::Array)
99
+ @periods = validate_and_normalize_periods(periods).freeze
100
+
101
+ freeze
102
+ end
103
+
104
+ # Get player style
105
+ #
106
+ # @return [Sashite::Snn::Name, nil] style or nil if not defined
107
+ #
108
+ # @example
109
+ # player.style # => #<Sashite::Snn::Name ...>
110
+ def style
111
+ @style
112
+ end
113
+
114
+ # Get player name
115
+ #
116
+ # @return [String, nil] name or nil if not defined
117
+ #
118
+ # @example
119
+ # player.name # => "Carlsen"
120
+ def name
121
+ @name
122
+ end
123
+
124
+ # Get player Elo rating
125
+ #
126
+ # @return [Integer, nil] elo or nil if not defined
127
+ #
128
+ # @example
129
+ # player.elo # => 2830
130
+ def elo
131
+ @elo
132
+ end
133
+
134
+ # Get time control periods
135
+ #
136
+ # @return [Array<Hash>] periods if not defined
137
+ #
138
+ # @example
139
+ # player.periods
140
+ # # => [
141
+ # # { time: 300, moves: nil, inc: 3 }
142
+ # # ]
143
+ def periods
144
+ @periods
145
+ end
146
+
147
+ # Check if player has time control
148
+ #
149
+ # @return [Boolean] true if periods are defined
150
+ #
151
+ # @example
152
+ # player.has_time_control? # => true
153
+ def has_time_control?
154
+ !unlimited_time?
155
+ end
156
+
157
+ # Check if player has unlimited time (no time control)
158
+ #
159
+ # @return [Boolean] true if periods is empty array or nil
160
+ #
161
+ # @example
162
+ # player.unlimited_time? # => false
163
+ def unlimited_time?
164
+ @periods.empty?
165
+ end
166
+
167
+ # Get initial time budget (sum of all period times)
168
+ #
169
+ # @return [Integer, nil] total seconds or nil if no periods
170
+ #
171
+ # @example
172
+ # player.initial_time_budget # => 7200 (2 hours)
173
+ def initial_time_budget
174
+ return if unlimited_time?
175
+
176
+ @periods.sum { |period| period[:time] }
177
+ end
178
+
179
+ # Check if no player information is present
180
+ #
181
+ # @return [Boolean] true if all fields are nil
182
+ #
183
+ # @example
184
+ # player.empty? # => true
185
+ def empty?
186
+ @style.nil? && @name.nil? && @elo.nil? && @periods.empty?
187
+ end
188
+
189
+ # Convert to hash representation
190
+ #
191
+ # Returns a hash containing only defined (non-nil) fields.
192
+ # If all fields are nil, returns an empty hash.
193
+ #
194
+ # @return [Hash] hash with :style, :name, :elo, and/or :periods keys
195
+ #
196
+ # @example Complete player
197
+ # player.to_h
198
+ # # => {
199
+ # # style: "CHESS",
200
+ # # name: "Carlsen",
201
+ # # elo: 2830,
202
+ # # periods: [
203
+ # # { time: 5400, moves: 40, inc: 0 },
204
+ # # { time: 1800, moves: nil, inc: 30 }
205
+ # # ]
206
+ # # }
207
+ #
208
+ # @example Partial player
209
+ # player.to_h
210
+ # # => { name: "Alice", periods: [] }
211
+ #
212
+ # @example Empty player
213
+ # player.to_h
214
+ # # => {}
215
+ def to_h
216
+ result = {}
217
+ result[:style] = @style.to_s unless @style.nil?
218
+ result[:name] = @name unless @name.nil?
219
+ result[:elo] = @elo unless @elo.nil?
220
+ result[:periods] = @periods unless @periods.empty?
221
+ result
222
+ end
223
+
224
+ # String representation for debugging
225
+ #
226
+ # @return [String] string representation
227
+ def inspect
228
+ attrs = []
229
+ attrs << "style=#{@style.inspect}" if @style
230
+ attrs << "name=#{@name.inspect}" if @name
231
+ attrs << "elo=#{@elo.inspect}" if @elo
232
+ attrs << "periods=#{@periods.inspect}" if @periods.any?
233
+
234
+ "#<#{self.class.name} #{attrs.join(' ')}>"
235
+ end
236
+
237
+ # Check equality with another Player object
238
+ #
239
+ # @param other [Object] object to compare
240
+ # @return [Boolean] true if equal
241
+ def ==(other)
242
+ return false unless other.is_a?(self.class)
243
+
244
+ @style == other.style &&
245
+ @name == other.name &&
246
+ @elo == other.elo &&
247
+ @periods == other.periods
248
+ end
249
+
250
+ alias eql? ==
251
+
252
+ # Hash code for use in collections
253
+ #
254
+ # @return [Integer] hash code
255
+ def hash
256
+ [@style, @name, @elo, @periods].hash
257
+ end
258
+
259
+ private
260
+
261
+ # Validate and normalize periods array
262
+ #
263
+ # @param periods [Array<Hash>] array of period hashes
264
+ # @return [Array<Hash>] normalized periods with all required fields
265
+ # @raise [ArgumentError] if any period is invalid
266
+ def validate_and_normalize_periods(periods)
267
+ periods.map.with_index do |period, index|
268
+ validate_and_normalize_period(period, index)
269
+ end
270
+ end
271
+
272
+ # Validate and normalize a single period
273
+ #
274
+ # @param period [Hash] period hash
275
+ # @param index [Integer] index for error messages
276
+ # @return [Hash] normalized period with time, moves, and inc fields
277
+ # @raise [ArgumentError] if period is invalid
278
+ def validate_and_normalize_period(period, index)
279
+ raise ::ArgumentError, "#{ERROR_INVALID_PERIOD} at index #{index}" unless period.is_a?(::Hash)
280
+
281
+ # Convert keys to symbols for consistent access
282
+ period = period.transform_keys(&:to_sym)
283
+
284
+ # Validate required 'time' field
285
+ raise ::ArgumentError, "#{ERROR_MISSING_TIME} at index #{index}" unless period.key?(:time)
286
+
287
+ time = period[:time]
288
+ raise ::ArgumentError, "#{ERROR_INVALID_TIME} at index #{index}" unless time.is_a?(::Integer) && time >= 0
289
+
290
+ # Validate optional 'moves' field (nil or integer >= 1)
291
+ moves = period[:moves]
292
+ unless moves.nil? || (moves.is_a?(::Integer) && moves >= 1)
293
+ raise ::ArgumentError, "#{ERROR_INVALID_MOVES} at index #{index}"
294
+ end
295
+
296
+ # Validate optional 'inc' field (defaults to 0)
297
+ inc = period.fetch(:inc, 0)
298
+ raise ::ArgumentError, "#{ERROR_INVALID_INC} at index #{index}" unless inc.is_a?(::Integer) && inc >= 0
299
+
300
+ # Return normalized period with all three fields
301
+ {
302
+ time: time,
303
+ moves: moves,
304
+ inc: inc
305
+ }.freeze
306
+ end
307
+ end
308
+ end
309
+ end
310
+ end
311
+ end